Files
ubuntu-post-install/ubuntu-post-install-26.04-crowdsec.sh
T
Claude b6af49f1f1 Add optional ntfy ban alerts to CrowdSec variants
When configuring CrowdSec, optionally wire up an ntfy push notification via
CrowdSec's HTTP notification plugin: writes /etc/crowdsec/notifications/ntfy.yaml
and references it from the default profile in profiles.yaml. Alerts fire on a
ban decision (after repeated failed attempts), not on every failed login.

Document the behavior in SCRIPT-VARIANTS.md, including why Authelia (email-only)
doesn't cover failed-login push. Both crowdsec variants verified with 'bash -n'
and a --dry-run --unattended pass (exit 0).

https://claude.ai/code/session_017eA2qqq9jfF2tNtpUYL8vK
2026-06-03 12:05:13 +00:00

7355 lines
298 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# Ubuntu 26.04 Post-Installation Script
# Run with: sudo bash post-install.sh
# This script is rerunnable - it detects existing installations
# ============================================================================
# COMMAND-LINE ARGUMENT PARSING
# ============================================================================
DRY_RUN=false
UNATTENDED=false
LOG_FILE="/var/log/post-install.log"
WHIPTAIL_USED=false
show_help() {
echo "Ubuntu 26.04 Post-Installation Script"
echo ""
echo "Usage: sudo ./post-install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --dry-run Preview what would be installed without making changes"
echo " --unattended Run with default options (no prompts)"
echo " Defaults: skip key generation, no SSH imports,"
echo " install Docker, skip VPNs/Remote Desktop/Backup"
echo " --restore Disaster recovery mode - restore from backup"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " sudo ./post-install.sh # Interactive mode"
echo " sudo ./post-install.sh --dry-run # Preview installations"
echo " sudo ./post-install.sh --unattended # Automated install"
echo " sudo ./post-install.sh --restore # Disaster recovery"
echo ""
exit 0
}
RESTORE_MODE=false
for arg in "$@"; do
case $arg in
--dry-run)
DRY_RUN=true
;;
--unattended)
UNATTENDED=true
;;
--restore)
RESTORE_MODE=true
;;
--help|-h)
show_help
;;
*)
echo "Unknown option: $arg"
echo "Use --help for usage information"
exit 1
;;
esac
done
# ============================================================================
# LOGGING SETUP
# ============================================================================
# Create log file and tee all output
exec > >(tee -a "$LOG_FILE") 2>&1
echo ""
echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE"
echo "=== Ubuntu 26.04 Post-Installation Script ==="
echo ""
if [ "$DRY_RUN" = true ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "DRY RUN MODE - No changes will be made"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
fi
if [ "$UNATTENDED" = true ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "UNATTENDED MODE - Using default options"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
fi
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
# Get the actual user (not root)
ACTUAL_USER="${SUDO_USER:-$USER}"
ACTUAL_HOME=$(getent passwd "$ACTUAL_USER" | cut -d: -f6)
DOCKER_DIR="$ACTUAL_HOME/docker"
echo "Note: Script will continue even if individual packages fail to install"
echo "Log file: $LOG_FILE"
echo ""
# ============================================================================
# INSTALLATION MODE SELECTOR
# ============================================================================
INSTALL_MODE="normal"
# Disaster recovery function
run_disaster_recovery() {
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "DISASTER RECOVERY MODE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "This will restore your system and Docker apps from a Kopia backup."
echo ""
echo "Requirements:"
echo " • Backup drive connected (with kopia-repo folder)"
echo " • Kopia password (saved in .env or you remember it)"
echo ""
# Step 1: Install core utilities
echo "Step 1: Installing core utilities"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Installing essential packages..."
apt-get update
apt-get install -y \
net-tools \
openssh-server \
git \
curl \
wget \
htop \
ncdu \
tree \
zip \
unzip \
whiptail \
2>/dev/null || echo " ⚠ Some packages may have failed"
# Install Kopia for backup management
echo "Installing Kopia..."
if ! command -v kopia &> /dev/null; then
curl -s https://kopia.io/signing-key | gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg 2>/dev/null
echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] https://packages.kopia.io/apt/ stable main" | tee /etc/apt/sources.list.d/kopia.list
apt-get update
apt-get install -y kopia 2>/dev/null || echo " ⚠ Kopia install failed"
fi
# Start SSH
systemctl enable ssh 2>/dev/null || true
systemctl start ssh 2>/dev/null || true
echo "✓ Core utilities installed"
echo ""
# Step 2: Find/mount backup drive
echo "Step 2: Locate backup drive"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Available block devices:"
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null || lsblk
echo ""
# Check if drives are already mounted
if [ -d "$ACTUAL_HOME/drives" ]; then
echo "Existing mount points in ~/drives/:"
ls -la "$ACTUAL_HOME/drives/" 2>/dev/null || echo " (none)"
echo ""
fi
read -p "Enter path to backup drive or Kopia repo (e.g., /dev/sdb1 or ~/drives/backup1): " BACKUP_PATH
# If it's a device, mount it
if [[ "$BACKUP_PATH" == /dev/* ]]; then
MOUNT_POINT="$ACTUAL_HOME/drives/restore-backup"
mkdir -p "$MOUNT_POINT" 2>/dev/null
echo "Mounting $BACKUP_PATH to $MOUNT_POINT..."
mount "$BACKUP_PATH" "$MOUNT_POINT" || { echo "Failed to mount. Check device path."; return 1; }
BACKUP_PATH="$MOUNT_POINT"
fi
# Expand ~ to home directory
BACKUP_PATH="${BACKUP_PATH/#\~/$ACTUAL_HOME}"
# Look for kopia-repo
KOPIA_REPO=""
if [ -d "$BACKUP_PATH/kopia-repo" ]; then
KOPIA_REPO="$BACKUP_PATH/kopia-repo"
elif [ -d "$BACKUP_PATH/kopia.repository" ] || [ -f "$BACKUP_PATH/kopia.repository.f" ]; then
KOPIA_REPO="$BACKUP_PATH"
else
echo ""
echo "Looking for Kopia repository..."
FOUND_REPO=$(find "$BACKUP_PATH" -maxdepth 3 -name "kopia.repository*" -type f 2>/dev/null | head -1)
if [ -n "$FOUND_REPO" ]; then
KOPIA_REPO=$(dirname "$FOUND_REPO")
fi
fi
if [ -z "$KOPIA_REPO" ] || [ ! -d "$KOPIA_REPO" ]; then
echo "❌ Could not find Kopia repository at $BACKUP_PATH"
echo " Look for a folder containing 'kopia.repository' files"
return 1
fi
echo "✓ Found Kopia repository at: $KOPIA_REPO"
echo ""
# Step 3: Get Kopia password
echo "Step 3: Kopia password"
echo "━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Try to find password in backed-up .env
if [ -f "$BACKUP_PATH/kopia/.env" ]; then
KOPIA_PASS=$(grep "KOPIA_PASSWORD" "$BACKUP_PATH/kopia/.env" 2>/dev/null | cut -d= -f2)
if [ -n "$KOPIA_PASS" ]; then
echo "Found password in backup. Use this? (y/n)"
read -p "> " USE_FOUND_PASS
[ "$USE_FOUND_PASS" != "y" ] && KOPIA_PASS=""
fi
fi
if [ -z "$KOPIA_PASS" ]; then
read -s -p "Enter Kopia repository password: " KOPIA_PASS
echo ""
fi
# Step 4: Install Docker if needed
echo ""
echo "Step 4: Ensure Docker is installed"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if ! command -v docker &> /dev/null; then
echo "Installing Docker..."
apt-get update
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
usermod -aG docker "$ACTUAL_USER"
echo "✓ Docker installed"
else
echo "✓ Docker already installed"
fi
echo ""
# Step 5: List snapshots and let user choose
echo "Step 5: Select snapshot to restore"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Connecting to repository and listing snapshots..."
echo ""
# Use docker to run kopia
SNAPSHOT_LIST=$(docker run --rm \
-v "$KOPIA_REPO:/repository" \
-e KOPIA_PASSWORD="$KOPIA_PASS" \
kopia/kopia:latest \
snapshot list --all 2>&1)
if echo "$SNAPSHOT_LIST" | grep -q "invalid password"; then
echo "❌ Invalid password"
return 1
fi
echo "$SNAPSHOT_LIST"
echo ""
read -p "Enter snapshot ID to restore (or 'latest' for most recent): " SNAPSHOT_ID
if [ "$SNAPSHOT_ID" = "latest" ]; then
SNAPSHOT_ID=$(echo "$SNAPSHOT_LIST" | grep -oE "^[a-f0-9]+" | tail -1)
echo "Using latest snapshot: $SNAPSHOT_ID"
fi
# Step 6: Restore to temp location
echo ""
echo "Step 6: Restoring from backup"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
RESTORE_TEMP="$ACTUAL_HOME/docker-restore-temp"
mkdir -p "$RESTORE_TEMP"
echo "Restoring to $RESTORE_TEMP..."
docker run --rm \
-v "$KOPIA_REPO:/repository" \
-v "$RESTORE_TEMP:/restore" \
-e KOPIA_PASSWORD="$KOPIA_PASS" \
kopia/kopia:latest \
restore "$SNAPSHOT_ID" /restore
if [ $? -ne 0 ]; then
echo "❌ Restore failed"
return 1
fi
echo "✓ Snapshot restored to temp location"
echo ""
# Step 7: Detect and select services to restore
echo "Step 7: Select services to restore"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
mkdir -p "$DOCKER_DIR"
ensure_docker_dir_ownership "$DOCKER_DIR"
# Find all docker-compose.yml files in restored backup
declare -A SERVICE_DIRS
SERVICES_FOUND=()
for compose_file in $(find "$RESTORE_TEMP" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null); do
SERVICE_DIR=$(dirname "$compose_file")
SERVICE_NAME=$(basename "$SERVICE_DIR")
SERVICES_FOUND+=("$SERVICE_NAME")
SERVICE_DIRS["$SERVICE_NAME"]="$SERVICE_DIR"
done
if [ ${#SERVICES_FOUND[@]} -eq 0 ]; then
echo "No Docker services found in backup"
else
echo "Found ${#SERVICES_FOUND[@]} services in backup."
echo ""
# Check if whiptail is available for nice UI
if command -v whiptail &> /dev/null; then
# Build whiptail checklist arguments
CHECKLIST_ARGS=()
for svc in "${SERVICES_FOUND[@]}"; do
CHECKLIST_ARGS+=("$svc" "" "ON")
done
# Show checklist - returns selected items
SELECTED=$(whiptail --title "Select Services to Restore" \
--checklist "Use SPACE to select/deselect, ENTER to confirm:" \
20 60 12 \
"${CHECKLIST_ARGS[@]}" \
3>&1 1>&2 2>&3)
# Parse selected services (whiptail returns quoted strings)
SELECTED_SERVICES=()
for item in $SELECTED; do
# Remove quotes
svc=$(echo "$item" | tr -d '"')
SELECTED_SERVICES+=("$svc")
done
else
# Fallback: text-based selection
echo "Select services to restore:"
echo " [A] All services"
echo " [N] None (skip restore)"
echo " [S] Select individually"
echo ""
read -p "Choice (A/N/S) [A]: " RESTORE_CHOICE
case "${RESTORE_CHOICE^^}" in
N)
SELECTED_SERVICES=()
;;
S)
SELECTED_SERVICES=()
for svc in "${SERVICES_FOUND[@]}"; do
read -p " Restore $svc? (y/n) [y]: " RESTORE_SVC
if [ "$RESTORE_SVC" != "n" ] && [ "$RESTORE_SVC" != "N" ]; then
SELECTED_SERVICES+=("$svc")
fi
done
;;
*)
SELECTED_SERVICES=("${SERVICES_FOUND[@]}")
;;
esac
fi
# Restore selected services
echo ""
if [ ${#SELECTED_SERVICES[@]} -eq 0 ]; then
echo "No services selected for restore."
else
echo "Restoring ${#SELECTED_SERVICES[@]} services..."
echo ""
for svc in "${SELECTED_SERVICES[@]}"; do
SERVICE_DIR="${SERVICE_DIRS[$svc]}"
TARGET_DIR="$DOCKER_DIR/$svc"
echo " Restoring $svc..."
# Copy entire service directory
cp -r "$SERVICE_DIR" "$TARGET_DIR" 2>/dev/null || true
# Fix ownership
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TARGET_DIR" 2>/dev/null || true
echo " ✓ $svc restored to $TARGET_DIR"
done
fi
fi
# Step 8: Start services
echo ""
echo "Step 8: Starting services"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Count restored services
RESTORED_COUNT=0
for dir in "$DOCKER_DIR"/*/; do
if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
((RESTORED_COUNT++))
fi
done
if [ $RESTORED_COUNT -eq 0 ]; then
echo "No services to start."
else
echo "Start restored services?"
echo " [A] All services"
echo " [S] Select which to start"
echo " [N] None (start manually later)"
echo ""
read -p "Choice (A/S/N) [A]: " START_CHOICE
case "${START_CHOICE^^}" in
N)
echo "Services not started. Start manually with:"
echo " cd ~/docker/{service} && docker compose up -d"
;;
S)
for dir in "$DOCKER_DIR"/*/; do
if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
SERVICE_NAME=$(basename "$dir")
read -p " Start $SERVICE_NAME? (y/n) [y]: " START_SVC
if [ "$START_SVC" != "n" ] && [ "$START_SVC" != "N" ]; then
echo " Starting $SERVICE_NAME..."
(cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME"
fi
fi
done
;;
*)
for dir in "$DOCKER_DIR"/*/; do
if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
SERVICE_NAME=$(basename "$dir")
echo " Starting $SERVICE_NAME..."
(cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME"
fi
done
;;
esac
fi
# Cleanup
echo ""
read -p "Remove temporary restore files? (y/n): " CLEANUP
if [ "$CLEANUP" = "y" ]; then
rm -rf "$RESTORE_TEMP"
echo "✓ Cleaned up temp files"
fi
# Step 9: Reconnect Kopia for future backups
echo ""
echo "Step 9: Setting up future backups"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Reconnecting Kopia to backup repository for future backups..."
if command -v kopia &> /dev/null && [ -n "$KOPIA_REPO" ]; then
# Reconnect to the repository
if kopia repository connect filesystem --path="$KOPIA_REPO" --password="$KOPIA_PASSWORD" 2>/dev/null; then
echo "✓ Kopia reconnected to repository"
echo " Repository: $KOPIA_REPO"
# Verify backup scripts exist
if [ -f "$DOCKER_DIR/kopia/backup-containers.sh" ]; then
chmod +x "$DOCKER_DIR/kopia/backup-containers.sh" 2>/dev/null || true
echo "✓ Backup script ready: $DOCKER_DIR/kopia/backup-containers.sh"
else
echo " ⚠ Backup script not found - run normal install to set up"
fi
else
echo " ⚠ Could not reconnect to repository"
echo " Run manually: kopia repository connect filesystem --path=\"$KOPIA_REPO\""
fi
else
echo " ⚠ Kopia not available or repository not found"
echo " Run the normal install script to set up backups"
fi
echo ""
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "DISASTER RECOVERY COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Services restored to: $DOCKER_DIR"
echo ""
echo "Backups: $(command -v kopia &>/dev/null && kopia repository status &>/dev/null && echo "✓ Connected" || echo "⚠ Not connected")"
echo " Run backups: $DOCKER_DIR/kopia/backup-containers.sh"
echo ""
echo "To check running containers:"
echo " docker ps"
echo ""
echo "To view logs:"
echo " docker compose logs -f"
echo ""
echo "⚠️ Some services may need manual configuration:"
echo " • Frigate: Edit config/config.yml with camera URLs"
echo " • Caddy: Update Caddyfile with your domain"
echo " • ddclient: Edit config/ddclient.conf with DNS credentials"
echo ""
return 0
}
# Migration function - import existing Docker containers
run_migration() {
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "MIGRATION MODE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "This will migrate existing Docker containers to this script's structure."
echo "Containers will be copied (not moved) and versions preserved."
echo ""
# Step 1: Get source Docker directory
echo "Step 1: Locate existing containers"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Try to auto-detect Docker directories
DETECTED_DIRS=()
# Check common locations
for check_dir in /var/docker /opt/docker "$HOME/docker" /home/*/docker; do
if [ -d "$check_dir" ] && [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then
DETECTED_DIRS+=("$check_dir")
fi
done
# Check mounted drives: ~/drives/*, /mnt/*, /media/*
for mount_base in "$HOME/drives" "$HOME_DIR/drives" /mnt /media; do
if [ -d "$mount_base" ]; then
for mount_dir in "$mount_base"/*; do
if [ -d "$mount_dir/docker" ]; then
check_dir="$mount_dir/docker"
if [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then
DETECTED_DIRS+=("$check_dir")
fi
fi
done
fi
done
if [ ${#DETECTED_DIRS[@]} -gt 0 ]; then
echo "Auto-detected Docker directories:"
for i in "${!DETECTED_DIRS[@]}"; do
dir="${DETECTED_DIRS[$i]}"
count=$(find "$dir" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null | wc -l)
echo " [$((i+1))] $dir ($count containers)"
done
echo ""
echo "Enter a number to select, or type a custom path:"
else
echo "No Docker directories auto-detected."
echo ""
echo "Common locations:"
echo " • ~/docker"
echo " • ~/drives/primary/docker"
echo " • /var/docker"
echo " • /opt/docker"
echo " • /mnt/data/docker"
echo ""
echo "Enter the full path to your Docker directory:"
fi
echo ""
read -p "Source directory: " SOURCE_DOCKER_DIR
# Check if user entered a number (to select from detected list)
if [[ "$SOURCE_DOCKER_DIR" =~ ^[0-9]+$ ]] && [ ${#DETECTED_DIRS[@]} -gt 0 ]; then
idx=$((SOURCE_DOCKER_DIR - 1))
if [ $idx -ge 0 ] && [ $idx -lt ${#DETECTED_DIRS[@]} ]; then
SOURCE_DOCKER_DIR="${DETECTED_DIRS[$idx]}"
echo "Selected: $SOURCE_DOCKER_DIR"
fi
fi
# Expand ~ if used
SOURCE_DOCKER_DIR="${SOURCE_DOCKER_DIR/#\~/$HOME}"
if [ ! -d "$SOURCE_DOCKER_DIR" ]; then
echo "❌ Directory not found: $SOURCE_DOCKER_DIR"
return 1
fi
# Step 2: Scan for containers
echo ""
echo "Step 2: Scanning for containers"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
CONTAINERS_FOUND=()
CONTAINER_PATHS=()
while IFS= read -r compose_file; do
if [ -n "$compose_file" ]; then
container_dir=$(dirname "$compose_file")
container_name=$(basename "$container_dir")
CONTAINERS_FOUND+=("$container_name")
CONTAINER_PATHS+=("$container_dir")
fi
done < <(find "$SOURCE_DOCKER_DIR" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null)
if [ ${#CONTAINERS_FOUND[@]} -eq 0 ]; then
echo "❌ No Docker containers found in $SOURCE_DOCKER_DIR"
return 1
fi
echo "Found ${#CONTAINERS_FOUND[@]} container(s):"
for i in "${!CONTAINERS_FOUND[@]}"; do
container="${CONTAINERS_FOUND[$i]}"
path="${CONTAINER_PATHS[$i]}"
size=$(du -sh "$path" 2>/dev/null | cut -f1)
echo " [$((i+1))] $container ($size)"
done
echo ""
# Step 3: Select containers to migrate
echo "Step 3: Select containers to migrate"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
SELECTED_CONTAINERS=()
if command -v whiptail &> /dev/null && [ ${#CONTAINERS_FOUND[@]} -gt 3 ]; then
# Use whiptail for many containers
CHECKLIST_ARGS=()
for container in "${CONTAINERS_FOUND[@]}"; do
CHECKLIST_ARGS+=("$container" "" "ON")
done
SELECTED=$(whiptail --title "Select Containers to Migrate" \
--checklist "Use SPACE to select/deselect, ENTER to confirm:" \
20 60 12 \
"${CHECKLIST_ARGS[@]}" \
3>&1 1>&2 2>&3)
if [ $? -eq 0 ] && [ -n "$SELECTED" ]; then
# Parse whiptail output
for container in $SELECTED; do
# Remove quotes
container="${container//\"/}"
SELECTED_CONTAINERS+=("$container")
done
fi
else
# Text-based selection
echo "Select containers to migrate:"
echo " [A] All containers"
echo " [S] Select individually"
echo " [N] None (cancel)"
echo ""
read -p "Choice (A/S/N) [A]: " MIGRATE_CHOICE
case "${MIGRATE_CHOICE^^}" in
N)
echo "Migration cancelled."
return 0
;;
S)
for container in "${CONTAINERS_FOUND[@]}"; do
read -p " Migrate $container? (y/n) [y]: " MIGRATE_THIS
if [ "$MIGRATE_THIS" != "n" ] && [ "$MIGRATE_THIS" != "N" ]; then
SELECTED_CONTAINERS+=("$container")
fi
done
;;
*)
SELECTED_CONTAINERS=("${CONTAINERS_FOUND[@]}")
;;
esac
fi
if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then
echo "No containers selected."
return 0
fi
echo ""
echo "Will migrate ${#SELECTED_CONTAINERS[@]} container(s):"
for container in "${SELECTED_CONTAINERS[@]}"; do
echo " • $container"
done
echo ""
# Step 4: Stop running containers (optional)
echo "Step 4: Container status"
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Should running containers be stopped before migration?"
echo " • Stopping ensures clean copy of data/databases"
echo " • Not stopping may result in inconsistent state"
echo ""
read -p "Stop containers before copying? (y/n) [y]: " STOP_CONTAINERS
if [ "$STOP_CONTAINERS" != "n" ] && [ "$STOP_CONTAINERS" != "N" ]; then
echo "Stopping containers..."
for container in "${SELECTED_CONTAINERS[@]}"; do
for i in "${!CONTAINERS_FOUND[@]}"; do
if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then
container_dir="${CONTAINER_PATHS[$i]}"
if [ -f "$container_dir/docker-compose.yml" ] || [ -f "$container_dir/compose.yml" ]; then
echo " Stopping $container..."
(cd "$container_dir" && docker compose down 2>/dev/null) || true
fi
fi
done
done
echo ""
fi
# Step 5: Decide migration method
echo "Step 5: Migration method"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Check if source is on a different drive (mounted drive, not home on OS)
SOURCE_ON_EXTERNAL=false
if [[ "$SOURCE_DOCKER_DIR" == /mnt/* ]] || [[ "$SOURCE_DOCKER_DIR" == /media/* ]] || [[ "$SOURCE_DOCKER_DIR" == */drives/* ]]; then
SOURCE_ON_EXTERNAL=true
fi
MIGRATION_METHOD="copy"
if [ "$SOURCE_ON_EXTERNAL" = true ]; then
echo "Source is on a mounted drive: $SOURCE_DOCKER_DIR"
echo ""
echo "How would you like to migrate?"
echo ""
echo " [C] Copy - Copy containers to ~/docker on OS drive"
echo " Best for: old OS drive mounted temporarily"
echo ""
echo " [S] Symlink - Create ~/docker as symlink to source location"
echo " Best for: data drive you'll keep using"
echo ""
echo " [U] Use in-place - Use source location directly, no copy"
echo " Best for: already on your data drive"
echo ""
read -p "Method (C/S/U) [C]: " MIGRATION_METHOD
case "${MIGRATION_METHOD^^}" in
S)
MIGRATION_METHOD="symlink"
;;
U)
MIGRATION_METHOD="use"
;;
*)
MIGRATION_METHOD="copy"
;;
esac
else
echo "Source: $SOURCE_DOCKER_DIR"
echo "Target: $DOCKER_DIR"
echo ""
echo "Will copy containers to $DOCKER_DIR"
fi
echo ""
# Handle different migration methods
case "$MIGRATION_METHOD" in
symlink)
echo "Creating symlink: $DOCKER_DIR$SOURCE_DOCKER_DIR"
echo ""
# Check if target exists
if [ -e "$DOCKER_DIR" ] || [ -L "$DOCKER_DIR" ]; then
echo " ⚠ $DOCKER_DIR already exists"
read -p " Remove and create symlink? (y/n) [n]: " REMOVE_EXISTING
if [ "$REMOVE_EXISTING" = "y" ] || [ "$REMOVE_EXISTING" = "Y" ]; then
rm -rf "$DOCKER_DIR"
else
echo " Cancelled."
return 1
fi
fi
ln -s "$SOURCE_DOCKER_DIR" "$DOCKER_DIR"
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
echo "✓ Symlink created"
echo " Containers stay at: $SOURCE_DOCKER_DIR"
echo " Accessible via: $DOCKER_DIR$SOURCE_DOCKER_DIR"
MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]}
;;
use)
echo "Using source location directly"
echo ""
DOCKER_DIR="$SOURCE_DOCKER_DIR"
echo "✓ DOCKER_DIR set to: $DOCKER_DIR"
echo " No files copied - containers remain in place"
MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]}
;;
copy|*)
echo "Copying containers to $DOCKER_DIR"
echo ""
# Create target directory
mkdir -p "$DOCKER_DIR"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
MIGRATED_COUNT=0
for container in "${SELECTED_CONTAINERS[@]}"; do
for i in "${!CONTAINERS_FOUND[@]}"; do
if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then
source_dir="${CONTAINER_PATHS[$i]}"
target_dir="$DOCKER_DIR/$container"
# Check if already exists
if [ -d "$target_dir" ]; then
echo " ⚠ $container already exists at $target_dir"
read -p " Overwrite? (y/n) [n]: " OVERWRITE
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
echo " Skipping $container"
continue
fi
rm -rf "$target_dir"
fi
echo " Copying $container..."
cp -a "$source_dir" "$target_dir"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$target_dir"
((MIGRATED_COUNT++))
echo " ✓ $container copied"
fi
done
done
;;
esac
echo ""
echo "✓ Migrated $MIGRATED_COUNT container(s)"
echo ""
# Step 6: Update volume paths
echo "Step 6: Update volume paths"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Scanning docker-compose files for volume mounts that may need updating..."
echo ""
# Find all absolute paths in volume mounts (excluding relative paths like ./ or named volumes)
PATHS_FOUND=()
PATHS_FILES=()
for container in "${SELECTED_CONTAINERS[@]}"; do
compose_file="$DOCKER_DIR/$container/docker-compose.yml"
[ -f "$compose_file" ] || compose_file="$DOCKER_DIR/$container/compose.yml"
[ -f "$compose_file" ] || continue
# Extract volume mount paths (lines with : that look like /path:/container/path)
while IFS= read -r line; do
# Match absolute paths in volume mounts (starting with /)
if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*(/[^:]+): ]]; then
host_path="${BASH_REMATCH[1]}"
# Skip if it's a relative path or standard paths
if [[ "$host_path" != "./"* ]] && [[ "$host_path" != "/etc/"* ]] && [[ "$host_path" != "/var/run/"* ]]; then
# Check if this path doesn't exist on current system
if [ ! -e "$host_path" ]; then
# Avoid duplicates
if [[ ! " ${PATHS_FOUND[*]} " =~ " ${host_path} " ]]; then
PATHS_FOUND+=("$host_path")
PATHS_FILES+=("$compose_file")
fi
fi
fi
fi
done < "$compose_file"
done
if [ ${#PATHS_FOUND[@]} -eq 0 ]; then
echo "✓ No volume paths need updating (all paths exist or are relative)"
echo ""
else
echo "Found ${#PATHS_FOUND[@]} volume path(s) that don't exist on this system:"
echo ""
# Default new base path
DEFAULT_NEW_BASE="$HOME_DIR/drives/primary"
for i in "${!PATHS_FOUND[@]}"; do
old_path="${PATHS_FOUND[$i]}"
compose_file="${PATHS_FILES[$i]}"
container_name=$(basename "$(dirname "$compose_file")")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Container: $container_name"
echo "Old path: $old_path"
# Suggest a new path based on the old path structure
# Extract the last part of the path for suggestion
path_tail=$(basename "$old_path")
suggested_path="$DEFAULT_NEW_BASE/$path_tail"
echo "Suggested: $suggested_path"
echo ""
echo "Options:"
echo " [Enter] Accept suggested path"
echo " [S] Skip - keep original path"
echo " [path] Enter custom path"
echo ""
read -p "New path: " NEW_PATH_INPUT
case "$NEW_PATH_INPUT" in
""|" ")
new_path="$suggested_path"
;;
[Ss])
echo " Skipping - keeping original path"
continue
;;
*)
new_path="${NEW_PATH_INPUT/#\~/$HOME_DIR}"
;;
esac
# Update the compose file
echo " Updating: $old_path$new_path"
# Escape paths for sed (handle slashes)
old_escaped=$(printf '%s\n' "$old_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g')
new_escaped=$(printf '%s\n' "$new_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g')
sed -i "s|$old_path|$new_path|g" "$compose_file"
# Create directory if it doesn't exist
if [ ! -d "$new_path" ]; then
echo " Creating directory: $new_path"
mkdir -p "$new_path" 2>/dev/null || echo " ⚠ Could not create (may need manual creation)"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$new_path" 2>/dev/null || true
fi
echo " ✓ Updated"
echo ""
done
echo "✓ Volume paths updated"
echo ""
fi
# Step 7: Start migrated containers (optional)
echo "Step 7: Start containers"
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
read -p "Start migrated containers? (y/n) [y]: " START_MIGRATED
if [ "$START_MIGRATED" != "n" ] && [ "$START_MIGRATED" != "N" ]; then
for container in "${SELECTED_CONTAINERS[@]}"; do
target_dir="$DOCKER_DIR/$container"
if [ -d "$target_dir" ]; then
echo " Starting $container..."
(cd "$target_dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $container"
fi
done
echo ""
fi
# Step 8: Offer additional services
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "MIGRATION COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Containers migrated to: $DOCKER_DIR"
echo ""
echo "Would you like to install additional services not in your migration?"
echo "This will continue with the normal install process where you can"
echo "choose which additional apps to install (Immich, Frigate, etc.)"
echo ""
read -p "Continue to install additional services? (y/n) [y]: " INSTALL_MORE
if [ "$INSTALL_MORE" = "n" ] || [ "$INSTALL_MORE" = "N" ]; then
echo ""
echo "Migration complete. You can run this script again to install more services."
echo ""
echo "To check running containers:"
echo " docker ps"
echo ""
return 0
fi
# Return to continue with normal install
INSTALL_MODE="post-migration"
return 0
}
# Check for restore mode flag
if [ "$RESTORE_MODE" = true ]; then
run_disaster_recovery
exit $?
fi
# Interactive mode selector (if not unattended)
if [ "$UNATTENDED" != true ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "INSTALLATION MODE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " [N] Normal install - Fresh install or modify existing"
echo " [M] Migration - Import existing Docker containers"
echo " [R] Disaster recovery - Restore from Kopia backup"
echo ""
read -p "Select mode (N/M/R) [N]: " MODE_SELECT
case "${MODE_SELECT^^}" in
R)
run_disaster_recovery
exit $?
;;
M)
run_migration
# If migration returns 0 and INSTALL_MODE is post-migration, continue
if [ $? -ne 0 ]; then
exit 1
fi
if [ "$INSTALL_MODE" != "post-migration" ]; then
exit 0
fi
echo ""
echo "Continuing with additional service installation..."
echo ""
;;
*)
INSTALL_MODE="normal"
;;
esac
echo ""
fi
# ============================================================================
# DRIVE SETUP (Runs before everything else)
# ============================================================================
setup_drives() {
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "DRIVE SETUP"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "This script uses ~/drives/ for data storage:"
echo " ~/drives/primary - Main data drive (media, photos, etc.)"
echo " ~/drives/backup1 - Backup drive(s)"
echo ""
# Create base drives directory
mkdir -p "$ACTUAL_HOME/drives"
chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
# Check for existing mounts
if mount | grep -q "$ACTUAL_HOME/drives/"; then
echo "Existing mounted drives:"
df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}'
echo ""
prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES
else
echo "No drives currently mounted in ~/drives/"
echo ""
prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES
fi
if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then
echo "Skipping drive setup. You can run this script again later."
return 0
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "AVAILABLE BLOCK DEVICES"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
echo ""
# Detect drives without partition tables (new drives)
echo "Checking for new/unpartitioned drives..."
NEW_DRIVES=()
for disk in /dev/sd? /dev/nvme?n?; do
[ -b "$disk" ] || continue
# Check if disk has no partitions
if ! lsblk -n "$disk" | grep -q "part"; then
# Check if it has no partition table
if ! blkid "$disk" &>/dev/null; then
size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null)
NEW_DRIVES+=("$disk ($size)")
fi
fi
done
if [ ${#NEW_DRIVES[@]} -gt 0 ]; then
echo ""
echo "⚠️ Found unpartitioned drives (no filesystem):"
for drive in "${NEW_DRIVES[@]}"; do
echo " • $drive"
done
echo ""
prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES
if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then
for drive_info in "${NEW_DRIVES[@]}"; do
drive=$(echo "$drive_info" | cut -d' ' -f1)
echo ""
prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS
if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then
echo " Creating partition table on $drive..."
parted -s "$drive" mklabel gpt
parted -s "$drive" mkpart primary ext4 0% 100%
# Wait for partition to appear
sleep 2
# Format the partition
part="${drive}1"
[ -b "${drive}p1" ] && part="${drive}p1" # nvme drives
if [ -b "$part" ]; then
echo " Formatting $part as ext4..."
mkfs.ext4 -F "$part"
echo " ✓ Formatted $part"
else
echo " ⚠ Could not find partition after creation"
fi
fi
done
echo ""
echo "Updated block devices:"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
echo ""
fi
fi
# Configure mount points
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "CONFIGURE MOUNT POINTS"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Primary drive
echo "PRIMARY DRIVE (for media, photos, docker data):"
prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME
PRIMARY_NAME="${PRIMARY_NAME:-primary}"
mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME"
# Check if already mounted
if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then
echo " ✓ Already mounted"
else
echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:"
read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV
if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
# Add to fstab
PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null)
if [ -n "$PRIMARY_UUID" ]; then
# Check if not already in fstab
if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then
cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab
echo " ✓ Added to fstab"
fi
fi
fi
fi
# Backup drives
echo ""
echo "BACKUP DRIVES (optional, for rsync backups):"
prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS
NUM_BACKUPS="${NUM_BACKUPS:-0}"
# Validate number
case $NUM_BACKUPS in
0|1|2|3|4) ;;
*) NUM_BACKUPS=0 ;;
esac
declare -a BACKUP_NAMES
declare -a BACKUP_DEVS
for i in $(seq 1 $NUM_BACKUPS); do
echo ""
prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME"
BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}"
mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"
if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then
read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV"
BACKUP_DEVS[$i]="$BACKUP_DEV"
if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then
BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null)
if [ -n "$BACKUP_UUID" ]; then
if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then
echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab
echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab"
fi
fi
fi
else
echo " ✓ Already mounted"
fi
done
# Set permissions
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
# Mount all from fstab
echo ""
echo "Mounting drives from fstab..."
mount -a 2>/dev/null || true
echo ""
echo "Current mounts in ~/drives/:"
df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)"
echo ""
# Export drive names for later use
export PRIMARY_DRIVE_NAME="$PRIMARY_NAME"
export BACKUP_DRIVE_COUNT="$NUM_BACKUPS"
for i in $(seq 1 $NUM_BACKUPS); do
export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}"
done
echo "✓ Drive setup complete"
echo ""
}
# Run drive setup for normal installs (not migration/recovery)
if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then
setup_drives
fi
# ============================================================================
# SOFTWARE DETECTION FUNCTIONS
# ============================================================================
is_docker_installed() {
command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null
}
is_samba_installed() {
command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null
}
is_netbird_installed() {
command -v netbird &> /dev/null
}
is_rustdesk_installed() {
command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null
}
is_rclone_installed() {
command -v rclone &> /dev/null
}
is_rsync_installed() {
command -v rsync &> /dev/null
}
is_ufw_installed() {
command -v ufw &> /dev/null
}
is_wireguard_installed() {
command -v wg &> /dev/null
}
is_tailscale_installed() {
command -v tailscale &> /dev/null
}
is_teamviewer_installed() {
command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1
}
is_meshcentral_installed() {
# MeshCentral agent is typically installed as meshagent
command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ]
}
# ============================================================================
# DRY-RUN AND UNATTENDED HELPER FUNCTIONS
# ============================================================================
# Run a command, but skip it in dry-run mode
run_cmd() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would execute: $*"
return 0
else
"$@"
fi
}
# Ensure Docker directories are owned by the actual user (not root)
# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...]
ensure_docker_dir_ownership() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER"
return 0
fi
for dir in "$@"; do
if [ -d "$dir" ]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true
fi
done
}
# Generate a secure random password with alphanumeric characters only (no special chars)
# Usage: generate_password [length]
# Default length: 32
generate_password() {
local length="${1:-32}"
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
# Validate password (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)
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 CrowdSec (Caddy JSON access logs)
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 CrowdSec since password auth is still enabled
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "CROWDSEC (Recommended with password SSH)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Since password authentication is enabled, CrowdSec is recommended"
echo "to protect against brute-force SSH attacks. CrowdSec is the modern"
echo "successor to fail2ban and also adds geo-blocking and a community"
echo "IP-reputation blocklist."
echo ""
prompt_yn "Install and enable CrowdSec? (y/n):" "y" INSTALL_CROWDSEC
if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then
echo ""
echo "Installing CrowdSec..."
if [ "$DRY_RUN" != true ]; then
# Add the CrowdSec APT repository and install the agent
if ! command -v cscli &> /dev/null; then
curl -s https://install.crowdsec.net | sudo sh
sudo apt install -y crowdsec
else
echo "✓ CrowdSec is already installed"
fi
# Firewall bouncer enforces decisions via iptables/nftables
sudo apt install -y crowdsec-firewall-bouncer-iptables
# SSH brute-force + base Linux detection scenarios
sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux 2>/dev/null || true
run_cmd systemctl enable crowdsec
run_cmd systemctl restart crowdsec
run_cmd systemctl enable crowdsec-firewall-bouncer
run_cmd systemctl restart crowdsec-firewall-bouncer
echo "✓ CrowdSec installed and configured"
echo " - Detects SSH brute-force via /var/log/auth.log"
echo " - View active bans: sudo cscli decisions list"
fi
else
echo "Skipping CrowdSec installation."
fi
fi
INSTALL_CROWDSEC="${INSTALL_CROWDSEC:-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"
echo "[DRY-RUN] Would ensure openssh-server is installed (required for NetBird SSH)"
echo "[DRY-RUN] Would configure netbird systemd service with --allow-server-ssh"
else
# NetBird v0.60.0+ requires openssh-server for SSH access.
# It injects /etc/ssh/sshd_config.d/99-netbird.conf and listens on port 22022.
echo "Ensuring openssh-server is installed (required for NetBird SSH)..."
apt install -y openssh-server 2>/dev/null || echo "Warning: openssh-server install failed, continuing..."
systemctl enable ssh 2>/dev/null || true
systemctl start ssh 2>/dev/null || true
curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..."
# Persist --allow-server-ssh so this machine accepts NetBird SSH connections
# without requiring interactive re-authentication on every connection.
echo "Configuring NetBird to allow SSH server (persistent across reboots)..."
mkdir -p /etc/systemd/system/netbird.service.d
cat > /etc/systemd/system/netbird.service.d/ssh-server.conf << 'NETBIRD_OVERRIDE'
[Service]
ExecStart=
ExecStart=/usr/bin/netbird service run --allow-server-ssh
NETBIRD_OVERRIDE
systemctl daemon-reload 2>/dev/null || true
echo " ✓ NetBird will start with --allow-server-ssh on every boot"
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 (v0.60.0+ method):"
echo " • openssh-server is installed and --allow-server-ssh is persisted"
echo " • Enable SSH per-peer in the NetBird dashboard (Peers > [peer] > SSH)"
echo " • NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf automatically"
echo " • Connect from another NetBird peer: ssh user@<netbird-ip>"
echo " • Get peer IPs with: netbird status"
echo " • SSH will work without re-authenticating each connection"
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/resolute.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/resolute.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
apt update
apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..."
echo ""
echo "Tailscale installed. Setup instructions:"
echo ""
echo "Connect to Tailscale network:"
echo " sudo tailscale up"
echo ""
echo "This opens a browser to authenticate. After that:"
echo " tailscale status # View connected devices"
echo " tailscale ip # Show your Tailscale IP"
echo ""
echo "Tailscale SSH (optional - enable in admin console):"
echo " • Enable 'SSH' in Tailscale admin console for this machine"
echo " • Connect with: ssh user@device-name (uses Tailscale identity)"
echo " • No SSH keys needed - Tailscale handles authentication"
echo ""
fi
else
echo "Skipping Tailscale installation."
fi
fi # End SKIP_VPN_CONFIG
# On rerun, skip remote desktop configuration if already set up
SKIP_RD_CONFIG=false
if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "REMOTE DESKTOP SERVICES"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Remote desktop services already configured:"
is_rustdesk_installed && echo " ✓ RustDesk"
is_teamviewer_installed && echo " ✓ TeamViewer"
is_meshcentral_installed && echo " ✓ MeshCentral Agent"
echo ""
prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD
if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then
SKIP_RD_CONFIG=true
fi
fi
if [ "$SKIP_RD_CONFIG" != true ]; then
# RustDesk Installation
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "RUSTDESK REMOTE DESKTOP (Optional)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "RustDesk is an open-source remote desktop software."
echo ""
if is_rustdesk_installed; then
echo "RustDesk is already installed."
echo ""
prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK
else
prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK
fi
if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then
echo ""
echo "Installing RustDesk..."
echo " - RustDesk: Open-source remote desktop software"
echo ""
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would download latest RustDesk from GitHub"
echo "[DRY-RUN] Would install rustdesk .deb package"
else
# Download latest RustDesk .deb package
RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")')
RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb"
wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..."
if [ -f /tmp/rustdesk.deb ]; then
apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..."
rm /tmp/rustdesk.deb
echo "✓ RustDesk installed"
fi
fi
else
echo "Skipping RustDesk installation."
fi
# TeamViewer Installation
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "TEAMVIEWER REMOTE DESKTOP (Optional)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "TeamViewer is commercial remote desktop software with a free tier."
echo " • Cross-platform (Windows, Mac, Linux, mobile)"
echo " • Easy to use - no port forwarding needed"
echo " • Requires TeamViewer account for unattended access"
echo ""
if is_teamviewer_installed; then
echo "TeamViewer is already installed."
echo ""
prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER
else
prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER
fi
if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then
echo ""
echo "Installing TeamViewer..."
echo ""
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would download TeamViewer .deb package"
echo "[DRY-RUN] Would install teamviewer"
else
# Download TeamViewer .deb package
wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..."
if [ -f /tmp/teamviewer.deb ]; then
apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..."
rm /tmp/teamviewer.deb
echo ""
echo "TeamViewer installed. Setup instructions:"
echo ""
echo "Start TeamViewer:"
echo " teamviewer"
echo ""
echo "For unattended access:"
echo " 1. Open TeamViewer"
echo " 2. Go to Extras → Options → Security"
echo " 3. Set a personal password for unattended access"
echo " 4. Note your TeamViewer ID (shown in main window)"
echo ""
echo "✓ TeamViewer installed"
fi
fi
else
echo "Skipping TeamViewer installation."
fi
# MeshCentral Installation
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "MESHCENTRAL AGENT (Optional)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "MeshCentral is an open-source remote management solution."
echo " • Self-hosted or use public servers"
echo " • Web-based remote desktop (no client software needed)"
echo " • Terminal, file transfer, and remote desktop"
echo " • Requires a MeshCentral server to connect to"
echo ""
if is_meshcentral_installed; then
echo "MeshCentral Agent is already installed."
echo ""
prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL
else
prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL
fi
if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then
echo ""
echo "MeshCentral requires a server URL to connect to."
echo ""
echo "If you have a MeshCentral server, the agent install is typically done by:"
echo " 1. Log into your MeshCentral web interface"
echo " 2. Go to 'My Devices' → 'Add Agent'"
echo " 3. Download and run the Linux agent installer"
echo ""
echo "Example (replace with your server's URL):"
echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX"
echo " chmod +x meshagent"
echo " sudo ./meshagent -install"
echo ""
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would prompt for MeshCentral server URL"
echo "[DRY-RUN] Would download and install meshagent"
else
prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL
if [ -n "$MESHCENTRAL_URL" ]; then
wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed"
if [ -f /tmp/meshagent ]; then
chmod +x /tmp/meshagent
/tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed"
rm /tmp/meshagent
echo ""
echo "✓ MeshCentral Agent installed"
echo " Check your MeshCentral server - this device should appear shortly."
fi
else
echo ""
echo "No URL provided. Skipping MeshCentral agent installation."
echo "You can install later by downloading the agent from your MeshCentral server."
fi
fi
else
echo "Skipping MeshCentral installation."
fi
fi # End SKIP_RD_CONFIG
# ============================================================================
# SELF-HOSTED DOCKER APPLICATIONS (Optional)
# ============================================================================
# These applications run in Docker containers using docker-compose
# Each app is installed to ~/docker/{appname}/
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Install self-hosted applications using Docker Compose."
echo "Each app will be installed to ~/docker/{appname}/"
echo ""
echo "Note: Docker must be installed for these applications."
echo ""
if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then
echo "Docker is not installed. Skipping self-hosted applications."
echo "Install Docker first, then rerun this script."
else
# Create docker apps directory
DOCKER_DIR="$ACTUAL_HOME/docker"
if [ "$DRY_RUN" != true ]; then
mkdir -p "$DOCKER_DIR"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
fi
# ============================================================================
# DETECT DRIVES (ONCE FOR ALL SERVICES)
# ============================================================================
detect_drives
# ============================================================================
# SERVICE SELECTION MENU
# ============================================================================
# Use whiptail for service selection if available
if command -v whiptail &> /dev/null; then
# Detect existing services
echo "Detecting existing services..."
declare -A EXISTING_SERVICES
[ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON"
[ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON"
[ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON"
[ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON"
[ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON"
[ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON"
[ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON"
[ -d "$DOCKER_DIR/authelia" ] && EXISTING_SERVICES[AUTHELIA]="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 cscli &> /dev/null && EXISTING_SERVICES[CROWDSEC]="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} \
"AUTHELIA" "SSO + 2FA auth portal for Caddy" ${EXISTING_SERVICES[AUTHELIA]:-OFF} \
"CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \
"CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-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[AUTHELIA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUTHELIA \"SSO + 2FA auth portal\" ON"
[ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON"
[ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"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_AUTHELIA:="n"}
: ${INSTALL_CADDY:="n"}
: ${INSTALL_CROWDSEC:="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 "AUTHELIA"; then INSTALL_AUTHELIA="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="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 "AUTHELIA"; then uninstall_service "Authelia" "$DOCKER_DIR/authelia" "authelia"; 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 CrowdSec (system packages)
if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then
echo "Uninstalling CrowdSec..."
echo " Stopping CrowdSec services..."
systemctl stop crowdsec 2>/dev/null || true
systemctl stop crowdsec-firewall-bouncer 2>/dev/null || true
systemctl disable crowdsec 2>/dev/null || true
systemctl disable crowdsec-firewall-bouncer 2>/dev/null || true
echo " Removing CrowdSec packages..."
apt-get remove --purge -y crowdsec crowdsec-firewall-bouncer-iptables 2>/dev/null || true
echo " ✓ CrowdSec 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/crowdsec.enable=true"
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
# ---- 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/crowdsec.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"
# }
# }
# 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
# ---- AUTHELIA ----
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUTHELIA" ]; then
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ AUTHELIA - SSO & Two-Factor Authentication Portal │"
echo "│ Protects any Caddy subdomain with a single login + TOTP │"
echo "│ Port: 9091 (internal only, accessed via Caddy) │"
echo "└─────────────────────────────────────────────────────────────────┘"
prompt_yn "Install Authelia? (y/n):" "n" INSTALL_AUTHELIA
fi
if [ "$INSTALL_AUTHELIA" = "y" ] || [ "$INSTALL_AUTHELIA" = "Y" ]; then
AUTHELIA_DIR="$DOCKER_DIR/authelia"
check_service_exists "Authelia" "$AUTHELIA_DIR" AUTHELIA_RECONFIGURE
if [ "$AUTHELIA_RECONFIGURE" = "true" ]; then
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $AUTHELIA_DIR"
else
echo "Installing Authelia..."
mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data"
# Collect configuration from user
echo ""
echo " Authelia needs a few details to configure."
echo ""
prompt_text " Your domain (e.g., example.com):" "example.com" AUTHELIA_DOMAIN
prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER
prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY
prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL
prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST
prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT
prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER
prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS
prompt_text " Timezone (e.g., America/New_York):" "America/New_York" AUTHELIA_TZ
# Generate secrets
echo ""
echo " Generating secrets..."
JWT_SECRET=$(openssl rand -hex 32)
SESSION_SECRET=$(openssl rand -hex 32)
STORAGE_SECRET=$(openssl rand -hex 32)
echo "$JWT_SECRET" > "$AUTHELIA_DIR/config/secrets/jwt_secret"
echo "$SESSION_SECRET" > "$AUTHELIA_DIR/config/secrets/session_secret"
echo "$STORAGE_SECRET" > "$AUTHELIA_DIR/config/secrets/storage_secret"
echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password"
chmod 600 "$AUTHELIA_DIR/config/secrets/"*
echo " ✓ Secrets generated"
# Generate password hash for admin user
echo ""
echo " Generating password hash for admin user..."
prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS
AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \
authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \
| grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH")
if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then
echo " ⚠ Could not generate hash automatically. Run this after install:"
echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'"
echo " Then update $AUTHELIA_DIR/config/users.yml"
else
echo " ✓ Password hash generated"
fi
ensure_docker_dir_ownership "$AUTHELIA_DIR"
# Authelia requires its config/data owned by uid 1000
chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data"
cd "$AUTHELIA_DIR"
# .env file
cat > .env << AUTHELIA_ENV
MY_DOMAIN=${AUTHELIA_DOMAIN}
SMTP_USER=${AUTHELIA_SMTP_USER}
DOCKER_MY_NETWORK=caddy_net
TZ=${AUTHELIA_TZ}
AUTHELIA_ENV
# docker-compose.yml
cat > docker-compose.yml << 'AUTHELIA_COMPOSE'
name: authelia
services:
authelia:
image: authelia/authelia:4.39.20
pull_policy: missing
container_name: authelia
user: "1000:1000"
volumes:
- ./config:/config
- ./data:/data
environment:
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret
- AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password
- AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER}
- AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}>
expose:
- 9091
restart: unless-stopped
networks:
- caddy_net
networks:
caddy_net:
external: true
AUTHELIA_COMPOSE
# configuration.yml
cat > config/configuration.yml << AUTHELIA_CONFIG
---
# Authelia configuration
# Secrets are injected via AUTHELIA_* environment variables in docker-compose.yml
theme: dark
server:
address: tcp://0.0.0.0:9091
log:
level: info
file_path: /data/authelia.log
totp:
period: 30
skew: 1
authentication_backend:
file:
path: /config/users.yml
password:
algorithm: argon2
argon2:
variant: argon2id
iterations: 3
memory: 65536
parallelism: 4
key_length: 32
salt_length: 16
access_control:
default_policy: deny
rules:
- domain: "*.${AUTHELIA_DOMAIN}"
policy: two_factor
session:
name: authelia_session
expiration: 12h
inactivity: 2h
remember_me: 7d
cookies:
- domain: ${AUTHELIA_DOMAIN}
authelia_url: https://auth.${AUTHELIA_DOMAIN}
default_redirection_url: https://${AUTHELIA_DOMAIN}
storage:
local:
path: /data/db.sqlite3
notifier:
disable_startup_check: false
smtp:
address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT}
timeout: 10s
identifier: localhost
subject: "[Authelia] {title}"
startup_check_address: ${AUTHELIA_SMTP_USER}
disable_require_tls: false
disable_starttls: false
AUTHELIA_CONFIG
# users.yml
cat > config/users.yml << AUTHELIA_USERS
---
# Authelia users database
# To add users: copy a block, update username/email/displayname, restart authelia
# To generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword'
# Login with username (not email). Tell users to use "Forgot Password" to set their own password.
users:
${AUTHELIA_ADMIN_USER}:
displayname: "${AUTHELIA_ADMIN_DISPLAY}"
email: ${AUTHELIA_ADMIN_EMAIL}
password: "${AUTHELIA_HASH}"
groups:
- admins
- users
AUTHELIA_USERS
chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data"
echo " ✓ Authelia configured at $AUTHELIA_DIR"
echo ""
# Ensure caddy_net Docker network exists
if ! docker network ls --format '{{.Name}}' | grep -q "^caddy_net$"; then
echo " Creating Docker network caddy_net..."
docker network create caddy_net && echo " ✓ caddy_net created" || echo " ⚠ Failed to create caddy_net"
else
echo " ✓ Docker network caddy_net already exists"
fi
# Inject Authelia snippet into Caddyfile if Caddy is installed
CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile"
if [ -f "$CADDY_FILE" ]; then
echo ""
echo " Configuring Caddy for Authelia..."
# Add (authelia) snippet at top if not already present
if ! grep -q "(authelia)" "$CADDY_FILE"; then
cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)"
AUTHELIA_SNIPPET=$(cat << 'SNIPPET_EOF'
# ── Authelia forward auth snippet ─────────────────────────────────────────────
(authelia) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
}
SNIPPET_EOF
)
# Prepend snippet before existing content
echo "$AUTHELIA_SNIPPET" | cat - "$CADDY_FILE" > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE"
echo " ✓ Authelia snippet added to Caddyfile"
else
echo " ✓ Authelia snippet already in Caddyfile"
fi
# Add auth portal site block if not already present
AUTH_DOMAIN="auth.${AUTHELIA_DOMAIN}"
if ! grep -q "$AUTH_DOMAIN" "$CADDY_FILE"; then
cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK
# ── Authelia login portal ──────────────────────────────────────────────────────
${AUTH_DOMAIN} {
reverse_proxy authelia:9091
log {
output file /var/log/caddy/auth.log
}
}
CADDY_AUTH_BLOCK
echo " ✓ Authelia portal block added for ${AUTH_DOMAIN}"
fi
# Reload Caddy if it's running
if docker ps --format '{{.Names}}' | grep -q "^caddy$"; then
docker exec -w /etc/caddy caddy caddy reload 2>/dev/null \
&& echo " ✓ Caddy reloaded" \
|| echo " ⚠ Caddy reload failed — reload manually after fixing Caddyfile"
fi
else
echo " Caddy not yet installed. Install Caddy and add this to your Caddyfile:"
echo ""
echo ' (authelia) {'
echo ' forward_auth authelia:9091 {'
echo ' uri /api/authz/forward-auth'
echo ' copy_headers Remote-User Remote-Groups Remote-Name Remote-Email'
echo ' }'
echo ' }'
echo ""
echo " auth.${AUTHELIA_DOMAIN} {"
echo " reverse_proxy authelia:9091"
echo " }"
echo ""
fi
prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA
if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then
docker compose up -d 2>/dev/null && echo " ✓ Authelia started" || echo " ⚠ Failed to start Authelia"
sleep 2
docker compose logs --tail=20 authelia 2>/dev/null || true
fi
echo ""
echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}"
echo " Config dir: $AUTHELIA_DIR/config"
echo " Users file: $AUTHELIA_DIR/config/users.yml"
echo " Data dir: $AUTHELIA_DIR/data"
echo ""
echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set real password)"
echo ""
echo " To protect a Caddy site, add 'import authelia' to its block:"
echo " myservice.${AUTHELIA_DOMAIN} {"
echo " import authelia"
echo " reverse_proxy localhost:PORT"
echo " }"
echo ""
echo " To add users:"
echo " Edit $AUTHELIA_DIR/config/users.yml then:"
echo " cd $AUTHELIA_DIR && docker compose restart authelia"
echo ""
fi
fi # End AUTHELIA_RECONFIGURE check
fi # End INSTALL_AUTHELIA check
# ---- CROWDSEC ----
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CROWDSEC" ]; then
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ CROWDSEC - Intrusion Prevention (fail2ban successor) │"
echo "│ Bans malicious IPs + geo-blocking + community IP reputation │"
echo "│ Protects SSH, Caddy, and other services │"
echo "└─────────────────────────────────────────────────────────────────┘"
prompt_yn "Install and configure CrowdSec? (y/n):" "n" INSTALL_CROWDSEC
fi
if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install CrowdSec + Caddy acquisition + firewall bouncer"
else
echo "Installing CrowdSec..."
# Install the CrowdSec agent if not already present
if command -v cscli &> /dev/null; then
echo " ✓ CrowdSec is already installed"
else
echo " Adding CrowdSec repository and installing agent..."
if curl -s https://install.crowdsec.net | sudo sh && sudo apt install -y crowdsec; then
echo " ✓ CrowdSec installed successfully"
else
echo " ⚠ Failed to install CrowdSec"
echo " See https://docs.crowdsec.net/ for manual installation"
fi
fi
# Firewall bouncer (enforces bans via iptables/nftables)
echo " Installing firewall bouncer..."
sudo apt install -y crowdsec-firewall-bouncer-iptables 2>/dev/null || \
echo " ⚠ Could not install firewall bouncer automatically"
# 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
# Detection collections: SSH, Caddy HTTP scenarios, base http
echo " Installing CrowdSec collections (sshd, caddy, base-http)..."
sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux crowdsecurity/caddy crowdsecurity/base-http-scenarios 2>/dev/null || \
echo " ⚠ Some collections may already be installed"
# Tell CrowdSec to read Caddy's JSON access logs
ACQUIS_FILE="/etc/crowdsec/acquis.d/caddy.yaml"
if [ ! -f "$ACQUIS_FILE" ]; then
echo " Creating Caddy log acquisition for CrowdSec..."
sudo mkdir -p /etc/crowdsec/acquis.d
ACQUIS_CONTENT='filenames:
- /var/log/caddy/*.log
- /var/log/caddy/*-access.log
labels:
type: caddy'
if echo "$ACQUIS_CONTENT" | sudo tee "$ACQUIS_FILE" > /dev/null; then
echo " ✓ Created Caddy acquisition ($ACQUIS_FILE)"
else
echo " ⚠ Failed to create acquisition - create it manually"
fi
else
echo " ✓ Caddy acquisition already exists"
fi
# Geo-blocking + reputation (the capability fail2ban/Authelia lack)
echo ""
echo " Geo-blocking & IP reputation (optional):"
echo " Enrich events with country/ASN data:"
echo " sudo cscli collections install crowdsecurity/geoip-enrich"
echo " Subscribe to community/3rd-party blocklists at:"
echo " https://app.crowdsec.net/"
# Optional: push ban alerts to ntfy
prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY
if [ "$CS_NTFY" = "y" ] || [ "$CS_NTFY" = "Y" ]; then
prompt_text " ntfy topic URL (e.g. https://ntfy.sh/my-crowdsec):" "https://ntfy.sh/crowdsec-alerts" CS_NTFY_URL
sudo mkdir -p /etc/crowdsec/notifications
NTFY_FILE="/etc/crowdsec/notifications/ntfy.yaml"
NTFY_CONTENT="type: http
name: ntfy
log_level: info
format: |
{{range . -}}
{{range .Decisions -}}
{{.Value}} banned: {{.Scenario}} for {{.Duration}}
{{end -}}
{{end -}}
url: $CS_NTFY_URL
method: POST
headers:
Title: CrowdSec ban
Priority: high
Tags: rotating_light"
if echo "$NTFY_CONTENT" | sudo tee "$NTFY_FILE" > /dev/null; then
echo " ✓ Created ntfy notification ($NTFY_FILE)"
# Wire the notification into the default profile (only once)
if ! grep -qE "^\s*- ntfy" /etc/crowdsec/profiles.yaml 2>/dev/null; then
sudo awk '1; /^on_success:/ && !d {print "notifications:"; print " - ntfy"; d=1}' \
/etc/crowdsec/profiles.yaml | sudo tee /etc/crowdsec/profiles.yaml.new > /dev/null \
&& sudo mv /etc/crowdsec/profiles.yaml.new /etc/crowdsec/profiles.yaml
echo " ✓ Enabled ntfy alerts in CrowdSec default profile"
else
echo " ✓ ntfy already referenced in CrowdSec profile"
fi
echo " Alerts fire when an IP is banned (after repeated failed attempts),"
echo " not on every individual failed login."
else
echo " ⚠ Failed to write ntfy notification config"
fi
fi
# Restart services to apply
prompt_yn "Restart CrowdSec to apply changes? (y/n):" "y" RESTART_CS
if [ "$RESTART_CS" = "y" ] || [ "$RESTART_CS" = "Y" ]; then
sudo systemctl enable crowdsec 2>/dev/null || true
if sudo systemctl restart crowdsec; then
echo " ✓ CrowdSec restarted successfully"
sudo systemctl enable crowdsec-firewall-bouncer 2>/dev/null || true
sudo systemctl restart crowdsec-firewall-bouncer 2>/dev/null || true
sleep 2
sudo cscli metrics 2>/dev/null | head -20 || true
else
echo " ⚠ Failed to restart CrowdSec"
echo " Check logs: sudo journalctl -u crowdsec -n 50"
fi
fi
echo ""
echo " Useful commands:"
echo " List active bans: sudo cscli decisions list"
echo " List alerts: sudo cscli alerts list"
echo " Manually ban IP: sudo cscli decisions add --ip 1.2.3.4"
echo " Unban IP: sudo cscli decisions delete --ip 1.2.3.4"
echo " Show metrics: sudo cscli metrics"
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 CrowdSec 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_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then
echo " ✓ CrowdSec - intrusion prevention (SSH/Caddy, geo + IP reputation)"
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 (v0.60.0+ method):"
echo " - Requires openssh-server running (installed by this script)"
echo " - Enable per-peer in NetBird dashboard: Peers > [peer] > SSH"
echo " - NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf"
echo " - Connect from another peer: ssh user@<netbird-ip>"
echo " - Get peer IPs with: netbird status"
echo " - Note: old 'netbird ssh <peer-name>' command no longer works"
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 " 4. Enable SSH per-peer: Peers > [peer] > SSH (in dashboard)"
echo " 5. Connect via SSH: ssh user@<netbird-ip>"
echo " Note: --allow-server-ssh is pre-configured in systemd override"
echo " (/etc/systemd/system/netbird.service.d/ssh-server.conf)"
echo " so SSH works without re-authenticating on every connection"
echo ""
fi
if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then
echo " RustDesk: Launch from applications menu or run 'rustdesk'"
echo ""
fi
if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then
echo " CrowdSec:"
echo " • Check metrics: sudo cscli metrics"
echo " • View active bans: sudo cscli decisions list"
echo " • Unban IP: sudo cscli decisions delete --ip <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