Issue: Option 3 deployment failed because traefik-network wasn't created, but the cleanup function didn't properly strip network references from files. Changes: - scripts/ez-homelab.sh: Add traefik-network creation in Step 2 (Network is harmless if unused - prevents docker compose errors) - Improve sed patterns in configure_remote_server_routing(): * Use anchored patterns (^ and $) to match exact lines * Fix network removal regex to match indentation correctly The traefik-network will exist but remain unused on additional servers. Services are accessed via core Traefik's manual HTTP routes.
2439 lines
93 KiB
Bash
Executable File
2439 lines
93 KiB
Bash
Executable File
#!/bin/bash
|
|
# EZ-Homelab Unified Setup & Deployment Script
|
|
|
|
# Removed set -e to allow graceful error handling
|
|
|
|
# Debug logging configuration
|
|
DEBUG=${DEBUG:-false}
|
|
VERBOSE=${VERBOSE:-false} # New verbosity toggle
|
|
DEBUG_LOG_FILE="/tmp/ez-homelab-debug.log"
|
|
|
|
# Colors for output
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m' # No Color
|
|
|
|
# Debug logging function
|
|
debug_log() {
|
|
if [ "$DEBUG" = true ]; then
|
|
echo "$(date '+%Y-%m-%d %H:%M:%S') [DEBUG] $1" >> "$DEBUG_LOG_FILE"
|
|
fi
|
|
}
|
|
|
|
# Initialize debug log
|
|
if [ "$DEBUG" = true ]; then
|
|
echo "$(date '+%Y-%m-%d %H:%M:%S') [DEBUG] ===== EZ-HOMELAB DEBUG LOG STARTED =====" > "$DEBUG_LOG_FILE"
|
|
debug_log "Script started with DEBUG=true"
|
|
debug_log "User: $USER, EUID: $EUID, PWD: $PWD"
|
|
fi
|
|
|
|
# Log functions
|
|
log_info() {
|
|
if [ "$VERBOSE" = true ]; then
|
|
echo -e "${BLUE}[INFO]${NC} $1"
|
|
fi
|
|
debug_log "[INFO] $1"
|
|
}
|
|
|
|
log_success() {
|
|
if [ "$VERBOSE" = true ]; then
|
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
|
fi
|
|
debug_log "[SUCCESS] $1"
|
|
}
|
|
|
|
log_warning() {
|
|
if [ "$VERBOSE" = true ]; then
|
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
|
fi
|
|
debug_log "[WARNING] $1"
|
|
}
|
|
|
|
log_error() {
|
|
echo -e "${RED}[ERROR]${NC} $1"
|
|
debug_log "[ERROR] $1"
|
|
}
|
|
|
|
# Safely load environment variables from .env file
|
|
load_env_file_safely() {
|
|
local env_file="$1"
|
|
debug_log "Loading env file safely: $env_file"
|
|
|
|
if [ ! -f "$env_file" ]; then
|
|
debug_log "Env file does not exist: $env_file"
|
|
return 1
|
|
fi
|
|
|
|
# Read the .env file line by line and export variables safely
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
# Skip comments and empty lines
|
|
[[ $line =~ ^[[:space:]]*# ]] && continue
|
|
[[ -z "$line" ]] && continue
|
|
|
|
# Parse KEY=VALUE, handling quoted values
|
|
if [[ $line =~ ^([^=]+)=(.*)$ ]]; then
|
|
local key="${BASH_REMATCH[1]}"
|
|
local value="${BASH_REMATCH[2]}"
|
|
|
|
# Strip inline comments
|
|
value=${value%%#*}
|
|
|
|
# Trim whitespace from key and value
|
|
key=$(echo "$key" | xargs)
|
|
value=$(echo "$value" | xargs)
|
|
|
|
# Strip carriage return if present (DOS line endings)
|
|
value=${value%$'\r'}
|
|
|
|
# Export the variable
|
|
export "$key"="$value"
|
|
|
|
debug_log "Exported $key=[HIDDEN]" # Don't log actual values for security
|
|
fi
|
|
done < "$env_file"
|
|
|
|
# Second pass: expand any ${VAR} references in loaded variables
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
# Skip comments and empty lines
|
|
[[ $line =~ ^[[:space:]]*# ]] && continue
|
|
[[ -z "$line" ]] && continue
|
|
|
|
# Parse KEY=VALUE
|
|
if [[ $line =~ ^([^=]+)=(.*)$ ]]; then
|
|
local key="${BASH_REMATCH[1]}"
|
|
key=$(echo "$key" | xargs)
|
|
|
|
# Get current value
|
|
local current_value="${!key}"
|
|
|
|
# Check if value contains ${...} and expand it
|
|
if [[ "$current_value" =~ \$\{[^}]+\} ]]; then
|
|
# Use eval to expand the variable reference safely within quotes
|
|
local expanded_value=$(eval echo "\"$current_value\"")
|
|
export "$key"="$expanded_value"
|
|
debug_log "Expanded $key variable reference"
|
|
fi
|
|
fi
|
|
done < "$env_file"
|
|
|
|
debug_log "Env file loaded successfully"
|
|
}
|
|
load_env_file() {
|
|
load_env_file_safely "$REPO_DIR/.env"
|
|
}
|
|
localize_yml_file() {
|
|
local file_path="$1"
|
|
local fail_on_missing="${2:-false}" # New parameter to control failure behavior
|
|
local missing_vars=""
|
|
local replaced_count=0
|
|
|
|
debug_log "localize_yml_file called for file: $file_path, fail_on_missing: $fail_on_missing"
|
|
|
|
if [ ! -f "$file_path" ]; then
|
|
log_warning "File $file_path does not exist, skipping YAML localization"
|
|
debug_log "File $file_path does not exist"
|
|
return
|
|
fi
|
|
|
|
# Check if file is writable
|
|
if [ ! -w "$file_path" ]; then
|
|
log_error "File $file_path is not writable, cannot localize"
|
|
debug_log "Permission denied for $file_path"
|
|
if [ "$fail_on_missing" = true ]; then
|
|
exit 1
|
|
fi
|
|
return
|
|
fi
|
|
|
|
# Backup only if target file already exists (not for repo sources)
|
|
if [[ "$file_path" != "$REPO_DIR"* ]] && [ -f "$file_path" ]; then
|
|
cp "$file_path" "$file_path.backup.$(date +%Y%m%d_%H%M%S)"
|
|
debug_log "Backed up $file_path"
|
|
fi
|
|
|
|
# Use envsubst to replace all ${VAR} with environment values, handling nested variables
|
|
if command -v envsubst >/dev/null 2>&1; then
|
|
log_info "DEBUG: DEFAULT_EMAIL=$DEFAULT_EMAIL"
|
|
temp_file="$file_path.tmp"
|
|
cp "$file_path" "$temp_file"
|
|
changed=true
|
|
while [ "$changed" = true ]; do
|
|
changed=false
|
|
new_content=$(envsubst < "$temp_file")
|
|
if [ "$new_content" != "$(cat "$temp_file")" ]; then
|
|
changed=true
|
|
echo "$new_content" > "$temp_file"
|
|
fi
|
|
done
|
|
mv "$temp_file" "$file_path"
|
|
debug_log "Replaced variables in $file_path using envsubst with nested expansion"
|
|
replaced_count=$(grep -o '\${[^}]*}' "$file_path" | wc -l)
|
|
replaced_count=$((replaced_count / 2)) # Approximate
|
|
else
|
|
log_warning "envsubst not available, cannot localize $file_path"
|
|
if [ "$fail_on_missing" = true ]; then
|
|
exit 1
|
|
fi
|
|
return
|
|
fi
|
|
|
|
# Post-replacement validation: check for remaining ${VAR} (except skipped)
|
|
local remaining_vars=$(grep -v '^[ \t]*#' "$file_path" | grep -o '\${[^}]*}' | sed 's/\${//' | sed 's/}//' | sort | uniq)
|
|
local invalid_remaining=""
|
|
for rvar in $remaining_vars; do
|
|
rvar=$(echo "$rvar" | xargs)
|
|
case "$rvar" in
|
|
"ACME_EMAIL"|"AUTHELIA_ADMIN_EMAIL"|"SMTP_USERNAME"|"SMTP_PASSWORD")
|
|
continue
|
|
;;
|
|
*)
|
|
invalid_remaining="$invalid_remaining $rvar"
|
|
;;
|
|
esac
|
|
done
|
|
if [ -n "$invalid_remaining" ]; then
|
|
log_error "Failed to replace variables in $file_path: $invalid_remaining"
|
|
debug_log "Unreplaced variables: $invalid_remaining"
|
|
if [ "$fail_on_missing" = true ]; then
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Handle missing variables
|
|
if [ -n "$missing_vars" ]; then
|
|
GLOBAL_MISSING_VARS="${GLOBAL_MISSING_VARS}${missing_vars}"
|
|
if [ "$fail_on_missing" = true ]; then
|
|
log_error "Critical environment variables missing: $missing_vars"
|
|
debug_log "Failing deployment due to missing critical variables: $missing_vars"
|
|
exit 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Enhanced placeholder replacement for all configuration files
|
|
localize_deployment() {
|
|
log_info "Starting deployment localization..."
|
|
|
|
local processed_files=0
|
|
GLOBAL_MISSING_VARS=""
|
|
|
|
# Process docker-compose files
|
|
if [ -d "$REPO_DIR/docker-compose" ]; then
|
|
while IFS= read -r -d '' file_path; do
|
|
if [ -f "$file_path" ]; then
|
|
debug_log "Processing docker-compose file: $file_path"
|
|
localize_yml_file "$file_path" false
|
|
processed_files=$((processed_files + 1))
|
|
fi
|
|
done < <(find "$REPO_DIR/docker-compose" -name "*.yml" -o -name "*.yaml" -print0 2>/dev/null)
|
|
fi
|
|
|
|
log_success "Deployment localization completed - processed $processed_files files"
|
|
debug_log "Localization completed for $processed_files files"
|
|
|
|
# Report aggregated missing variables
|
|
if [ -n "$GLOBAL_MISSING_VARS" ]; then
|
|
log_warning "Aggregated missing environment variables across all files: $GLOBAL_MISSING_VARS"
|
|
debug_log "Global missing vars: $GLOBAL_MISSING_VARS"
|
|
fi
|
|
}
|
|
|
|
# Function to generate shared CA for multi-server TLS
|
|
generate_shared_ca() {
|
|
local ca_dir="/opt/stacks/core/shared-ca"
|
|
mkdir -p "$ca_dir"
|
|
openssl genrsa -out "$ca_dir/ca-key.pem" 4096
|
|
openssl req -new -x509 -days 365 -key "$ca_dir/ca-key.pem" -sha256 -out "$ca_dir/ca.pem" -subj "/C=US/ST=State/L=City/O=Homelab/CN=Homelab-CA"
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ca_dir"
|
|
log_success "Shared CA generated"
|
|
}
|
|
|
|
# Setup SSH key authentication to core server
|
|
setup_ssh_key_to_core() {
|
|
local key_name="id_rsa_${SERVER_HOSTNAME}_to_core"
|
|
local key_path="/home/$ACTUAL_USER/.ssh/$key_name"
|
|
|
|
log_info "Setting up SSH key authentication to core server..."
|
|
|
|
# Ensure .ssh directory exists
|
|
mkdir -p "/home/$ACTUAL_USER/.ssh"
|
|
chmod 700 "/home/$ACTUAL_USER/.ssh"
|
|
|
|
# Check if key already exists
|
|
if [ -f "$key_path" ]; then
|
|
log_info "SSH key already exists: $key_path"
|
|
|
|
# Test if key works with aggressive timeout
|
|
log_info "Testing existing SSH key..."
|
|
if timeout 3 bash -c "LC_ALL=C ssh -i '$key_path' -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no \
|
|
-o ServerAliveInterval=1 -o ServerAliveCountMax=1 -o LogLevel=ERROR \
|
|
'${CORE_SERVER_USER}@${CORE_SERVER_IP}' 'echo test' 2>&1 | grep -v 'locale\|LC_ALL\|setlocale' | grep -q 'test'"; then
|
|
log_success "Existing SSH key works, skipping key setup"
|
|
export SSH_KEY_PATH="$key_path"
|
|
return 0
|
|
else
|
|
log_warning "Existing key doesn't work or connection failed, will regenerate and install"
|
|
rm -f "$key_path" "$key_path.pub"
|
|
fi
|
|
fi
|
|
|
|
# Generate new SSH key
|
|
log_info "Generating SSH key: $key_path"
|
|
ssh-keygen -t rsa -b 4096 -f "$key_path" -N "" \
|
|
-C "${SERVER_HOSTNAME}-to-core-${CORE_SERVER_HOSTNAME}" 2>&1 | grep -v "^Generating\|^Your identification\|^Your public key"
|
|
|
|
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
|
log_error "Failed to generate SSH key"
|
|
return 1
|
|
fi
|
|
|
|
log_success "SSH key generated"
|
|
|
|
# Install key on core server using password
|
|
log_info "Installing SSH key on core server ${CORE_SERVER_IP}..."
|
|
|
|
if ! command -v sshpass &> /dev/null; then
|
|
log_info "sshpass is not installed. Installing now..."
|
|
sudo apt-get update -qq && sudo apt-get install -y sshpass >/dev/null 2>&1
|
|
fi
|
|
|
|
# Debug: Check if password is set
|
|
if [ -z "$CORE_SERVER_PASSWORD" ]; then
|
|
log_error "CORE_SERVER_PASSWORD is empty!"
|
|
log_error "Check your .env file - ensure CORE_SERVER_PASSWORD is set correctly"
|
|
return 1
|
|
fi
|
|
|
|
# Show password length for debugging (not actual password)
|
|
log_info "Password length: ${#CORE_SERVER_PASSWORD} characters"
|
|
|
|
# Test SSH connection with password first
|
|
log_info "Testing SSH connection with password..."
|
|
if ! LC_ALL=C sshpass -p "$CORE_SERVER_PASSWORD" ssh \
|
|
-o StrictHostKeyChecking=no \
|
|
-o ConnectTimeout=10 \
|
|
-o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}" "echo 'SSH connection successful'" 2>&1 | grep -v "locale\|LC_ALL\|setlocale" | grep -q "successful"; then
|
|
log_error "SSH password authentication failed"
|
|
log_error "Please verify the password in your .env file is correct"
|
|
log_error "You can test manually: sshpass -p 'YOUR_PASSWORD' ssh ${CORE_SERVER_USER}@${CORE_SERVER_IP}"
|
|
return 1
|
|
fi
|
|
|
|
log_success "SSH password authentication works"
|
|
|
|
# Read the public key
|
|
local pub_key=$(cat "${key_path}.pub")
|
|
|
|
# Copy key to core server using direct SSH command (more reliable than ssh-copy-id)
|
|
log_info "Adding public key to authorized_keys on core server..."
|
|
LC_ALL=C sshpass -p "$CORE_SERVER_PASSWORD" ssh \
|
|
-o StrictHostKeyChecking=no \
|
|
-o ConnectTimeout=10 \
|
|
-o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}" \
|
|
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '$pub_key' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" 2>&1 | grep -v "locale\|LC_ALL\|setlocale"
|
|
|
|
if [ $? -ne 0 ]; then
|
|
log_error "Failed to copy SSH key to core server"
|
|
log_error "Please verify:"
|
|
echo " 1. Core server IP is correct: ${CORE_SERVER_IP}"
|
|
echo " 2. Username is correct: ${CORE_SERVER_USER}"
|
|
echo " 3. Password is correct"
|
|
echo " 4. SSH server is running on core server"
|
|
return 1
|
|
fi
|
|
|
|
# Verify key works
|
|
log_info "Verifying SSH key authentication..."
|
|
if LC_ALL=C ssh -i "$key_path" -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}" "echo 'SSH key authentication successful'" 2>&1 | grep -v "locale\|LC_ALL\|setlocale" | grep -q "successful"; then
|
|
log_success "SSH key authentication verified"
|
|
|
|
# Add SSH config entry for automatic key usage
|
|
log_info "Adding SSH config entry for core server..."
|
|
local ssh_config="/home/$ACTUAL_USER/.ssh/config"
|
|
|
|
# Create config file if it doesn't exist
|
|
touch "$ssh_config"
|
|
chmod 600 "$ssh_config"
|
|
|
|
# Check if entry already exists
|
|
if ! grep -q "Host ${CORE_SERVER_HOSTNAME}" "$ssh_config" 2>/dev/null; then
|
|
cat >> "$ssh_config" <<SSHCONFIG
|
|
|
|
# Auto-generated by EZ-Homelab for remote server ${SERVER_HOSTNAME}
|
|
Host ${CORE_SERVER_HOSTNAME}
|
|
HostName ${CORE_SERVER_IP}
|
|
User ${CORE_SERVER_USER}
|
|
IdentityFile ${key_path}
|
|
StrictHostKeyChecking no
|
|
UserKnownHostsFile /dev/null
|
|
LogLevel ERROR
|
|
SSHCONFIG
|
|
log_success "SSH config entry added for ${CORE_SERVER_HOSTNAME}"
|
|
else
|
|
log_info "SSH config entry already exists"
|
|
fi
|
|
|
|
# Export key path for use by other functions
|
|
export SSH_KEY_PATH="$key_path"
|
|
return 0
|
|
else
|
|
log_error "SSH key verification failed"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Function to setup multi-server TLS for remote servers
|
|
setup_multi_server_tls() {
|
|
local ca_dir="/opt/stacks/core/shared-ca"
|
|
|
|
# Use the SSH key path that was exported by setup_ssh_key_to_core
|
|
if [ -z "$SSH_KEY_PATH" ]; then
|
|
log_error "SSH_KEY_PATH not set. Please run setup_ssh_key_to_core first."
|
|
return 1
|
|
fi
|
|
|
|
local key_path="$SSH_KEY_PATH"
|
|
|
|
log_info "Using SSH key: $key_path"
|
|
|
|
sudo mkdir -p "$ca_dir"
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$ca_dir"
|
|
|
|
log_info "Fetching shared CA from core server ${CORE_SERVER_IP}..."
|
|
|
|
# Check if shared CA exists on core server
|
|
log_info "Checking for shared CA on core server..."
|
|
|
|
SHARED_CA_PATH=""
|
|
|
|
# Test for shared-ca directory (preferred location)
|
|
if LC_ALL=C ssh -i "$key_path" -o StrictHostKeyChecking=no -o LogLevel=ERROR "${CORE_SERVER_USER}@${CORE_SERVER_IP}" \
|
|
"test -f /opt/stacks/core/shared-ca/ca.pem && test -f /opt/stacks/core/shared-ca/ca-key.pem && echo 'EXISTS'" 2>/dev/null | grep -q "EXISTS"; then
|
|
SHARED_CA_PATH="/opt/stacks/core/shared-ca"
|
|
log_success "Found shared CA in: $SHARED_CA_PATH"
|
|
# Test for docker-tls directory (alternative location)
|
|
elif LC_ALL=C ssh -i "$key_path" -o StrictHostKeyChecking=no -o LogLevel=ERROR "${CORE_SERVER_USER}@${CORE_SERVER_IP}" \
|
|
"test -f /opt/stacks/core/docker-tls/ca.pem && test -f /opt/stacks/core/docker-tls/ca-key.pem && echo 'EXISTS'" 2>/dev/null | grep -q "EXISTS"; then
|
|
SHARED_CA_PATH="/opt/stacks/core/docker-tls"
|
|
log_success "Found shared CA in: $SHARED_CA_PATH"
|
|
fi
|
|
|
|
if [ -z "$SHARED_CA_PATH" ]; then
|
|
log_error "Shared CA not found on core server"
|
|
log_error "Please ensure core server is fully deployed with Option 2 first"
|
|
log_info "Checking what exists on core server..."
|
|
LC_ALL=C ssh -i "$key_path" -o StrictHostKeyChecking=no "${CORE_SERVER_USER}@${CORE_SERVER_IP}" \
|
|
"ls -la /opt/stacks/core/shared-ca/ /opt/stacks/core/docker-tls/ 2>&1" | grep -v "locale\|LC_ALL\|setlocale"
|
|
TLS_ISSUES_SUMMARY="⚠️ TLS Configuration Issue: Shared CA not found on core server ${CORE_SERVER_IP}
|
|
|
|
To fix this:
|
|
1. Deploy core server first using Option 2
|
|
2. Verify CA exists: ssh ${CORE_SERVER_USER}@${CORE_SERVER_IP} 'ls -la /opt/stacks/core/shared-ca/'
|
|
3. Re-run Option 3 deployment"
|
|
return 1
|
|
fi
|
|
|
|
# Copy shared CA from core server using SCP
|
|
log_info "Copying shared CA certificates..."
|
|
|
|
# Copy ca.pem
|
|
if ! LC_ALL=C scp -i "$key_path" -o StrictHostKeyChecking=no -o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}:${SHARED_CA_PATH}/ca.pem" \
|
|
"$ca_dir/" 2>/dev/null; then
|
|
log_error "Failed to copy ca.pem from core server"
|
|
TLS_ISSUES_SUMMARY="⚠️ TLS Configuration Issue: Could not copy shared CA from ${CORE_SERVER_IP}
|
|
|
|
To fix this:
|
|
1. Verify SSH key works: ssh -i $key_path ${CORE_SERVER_USER}@${CORE_SERVER_IP}
|
|
2. Check file permissions: ssh ${CORE_SERVER_USER}@${CORE_SERVER_IP} 'ls -la ${SHARED_CA_PATH}/'
|
|
3. Manually copy if needed: scp -i $key_path ${CORE_SERVER_USER}@${CORE_SERVER_IP}:${SHARED_CA_PATH}/ca* $ca_dir/"
|
|
return 1
|
|
fi
|
|
|
|
# Copy ca-key.pem
|
|
if ! LC_ALL=C scp -i "$key_path" -o StrictHostKeyChecking=no -o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}:${SHARED_CA_PATH}/ca-key.pem" \
|
|
"$ca_dir/" 2>/dev/null; then
|
|
log_error "Failed to copy ca-key.pem from core server"
|
|
return 1
|
|
fi
|
|
|
|
log_success "Shared CA copied successfully"
|
|
|
|
# Now setup Docker TLS using the shared CA
|
|
setup_docker_tls
|
|
}
|
|
|
|
# Get script directory and repo directory
|
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
REPO_DIR="$( cd "$SCRIPT_DIR/.." && pwd )"
|
|
|
|
# Source common functions
|
|
source "$REPO_DIR/scripts/common.sh"
|
|
|
|
# Get actual user
|
|
if [ "$EUID" -eq 0 ]; then
|
|
ACTUAL_USER=${SUDO_USER:-$USER}
|
|
else
|
|
ACTUAL_USER=$USER
|
|
fi
|
|
|
|
# Default values
|
|
DOMAIN=""
|
|
SERVER_IP=""
|
|
ADMIN_USER=""
|
|
ADMIN_EMAIL=""
|
|
AUTHELIA_ADMIN_PASSWORD=""
|
|
DEPLOY_CORE=false
|
|
DEPLOY_INFRASTRUCTURE=false
|
|
DEPLOY_DASHBOARDS=false
|
|
SETUP_STACKS=false
|
|
DEPLOY_REMOTE_SERVER=false
|
|
TLS_ISSUES_SUMMARY=""
|
|
CORE_SERVER_IP=""
|
|
CORE_SERVER_HOSTNAME=""
|
|
CORE_SERVER_USER=""
|
|
CORE_SERVER_PASSWORD=""
|
|
SSH_KEY_PATH=""
|
|
|
|
# Required variables for configuration
|
|
REQUIRED_VARS=("SERVER_IP" "SERVER_HOSTNAME" "DUCKDNS_SUBDOMAINS" "DUCKDNS_TOKEN" "DOMAIN" "DEFAULT_USER" "DEFAULT_PASSWORD" "DEFAULT_EMAIL")
|
|
|
|
# Load existing .env file if it exists
|
|
load_env_file() {
|
|
if [ -f "$REPO_DIR/.env" ]; then
|
|
log_info "Found existing .env file, loading current configuration..."
|
|
load_env_file_safely "$REPO_DIR/.env"
|
|
return 0
|
|
else
|
|
log_info "No existing .env file found. We'll create one during setup."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Validate variable values
|
|
validate_variable() {
|
|
local var_name="$1"
|
|
local var_value="$2"
|
|
|
|
case "$var_name" in
|
|
"SERVER_IP")
|
|
# Basic IP validation
|
|
if [[ $var_value =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
;;
|
|
"DOMAIN")
|
|
# Basic domain validation
|
|
if [[ $var_value =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
;;
|
|
"DUCKDNS_SUBDOMAINS")
|
|
# DuckDNS subdomain should be non-empty and contain only valid characters
|
|
local trimmed_value=$(echo "$var_value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
if [ -n "$trimmed_value" ] && [[ $trimmed_value =~ ^[a-zA-Z0-9.-]+$ ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
;;
|
|
"DEFAULT_PASSWORD")
|
|
# Password should be at least 8 characters
|
|
if [ ${#var_value} -ge 8 ]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
;;
|
|
"DEFAULT_EMAIL")
|
|
# Basic email validation
|
|
if [[ $var_value =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
;;
|
|
*)
|
|
# For other variables, trim whitespace and check they're not empty
|
|
local trimmed_value=$(echo "$var_value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
if [ -n "$trimmed_value" ]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Prompt for a single variable
|
|
prompt_for_variable() {
|
|
local var="$1"
|
|
local user_input=""
|
|
local current_value="${!var:-}"
|
|
local prompt_text=""
|
|
|
|
while true; do
|
|
# Build prompt text with current value if it exists
|
|
if [ -n "$current_value" ]; then
|
|
if [ "$var" = "DEFAULT_PASSWORD" ]; then
|
|
prompt_text="🔒 ${var} ([HIDDEN]): "
|
|
else
|
|
prompt_text="${var} (${current_value}): "
|
|
fi
|
|
else
|
|
prompt_text="${var}: "
|
|
fi
|
|
|
|
# Add icon prefix
|
|
case "$var" in
|
|
"SERVER_IP")
|
|
prompt_text="🌐 ${prompt_text}"
|
|
;;
|
|
"DOMAIN")
|
|
prompt_text="🌍 ${prompt_text}"
|
|
;;
|
|
"DUCKDNS_SUBDOMAINS")
|
|
prompt_text="🦆 ${prompt_text}"
|
|
;;
|
|
"DUCKDNS_TOKEN")
|
|
prompt_text="🔑 ${prompt_text}"
|
|
;;
|
|
"DEFAULT_USER")
|
|
prompt_text="👤 ${prompt_text}"
|
|
;;
|
|
"DEFAULT_PASSWORD")
|
|
# Lock icon already added above for passwords
|
|
;;
|
|
"DEFAULT_EMAIL")
|
|
prompt_text="📧 ${prompt_text}"
|
|
;;
|
|
"SERVER_HOSTNAME")
|
|
prompt_text="🏠 ${prompt_text}"
|
|
;;
|
|
esac
|
|
|
|
# Get user input
|
|
if [ "$var" = "DEFAULT_PASSWORD" ]; then
|
|
read -s -p "$prompt_text" user_input
|
|
echo ""
|
|
else
|
|
read -p "$prompt_text" user_input
|
|
fi
|
|
|
|
# Check for quit command
|
|
if [ "$user_input" = "q" ] || [ "$user_input" = "Q" ]; then
|
|
log_info "Setup cancelled by user"
|
|
exit 0
|
|
fi
|
|
|
|
if [ -z "$user_input" ]; then
|
|
if [ -n "$current_value" ]; then
|
|
# Use existing value - overwrite prompt with status
|
|
if [ "$var" != "DEFAULT_PASSWORD" ]; then
|
|
echo -e "\033[1A\033[K✅ ${var}: ${current_value}"
|
|
fi
|
|
return 0
|
|
else
|
|
log_warning "${var} cannot be empty. Please provide a value."
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
if validate_variable "$var" "$user_input"; then
|
|
eval "$var=\"$user_input\""
|
|
# Overwrite prompt with status
|
|
if [ "$var" != "DEFAULT_PASSWORD" ]; then
|
|
echo -e "\033[1A\033[K✅ ${var}: ${user_input}"
|
|
else
|
|
echo -e "\033[1A\033[K✅ ${var}: [HIDDEN]"
|
|
fi
|
|
return 0
|
|
else
|
|
log_warning "Invalid value for ${var}. Please try again."
|
|
continue
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Validate and prompt for required variables with loop
|
|
validate_and_prompt_variables() {
|
|
local all_valid=false
|
|
local user_wants_to_review=false
|
|
local first_display=true
|
|
|
|
while true; do
|
|
user_wants_to_review=false
|
|
|
|
all_valid=true
|
|
|
|
# Check validity without showing initial summary
|
|
for var in "${REQUIRED_VARS[@]}"; do
|
|
local display_value=$(echo "${!var:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
if [ -z "$display_value" ] || ! validate_variable "$var" "${!var}"; then
|
|
all_valid=false
|
|
fi
|
|
done
|
|
|
|
if [ "$all_valid" = true ]; then
|
|
if [ "$first_display" = true ]; then
|
|
echo "Current configuration:"
|
|
for var in "${REQUIRED_VARS[@]}"; do
|
|
local display_value=$(echo "${!var:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
if [ "$var" = "DEFAULT_PASSWORD" ]; then
|
|
echo " ✅ ${var}: [HIDDEN]"
|
|
else
|
|
echo " ✅ ${var}: ${display_value}"
|
|
fi
|
|
done
|
|
echo ""
|
|
first_display=false
|
|
fi
|
|
echo ""
|
|
echo "Choose an option:"
|
|
echo " 1) ✅ Deploy now"
|
|
echo " 2) 🔄 Make Changes"
|
|
echo " q) ❌ Quit setup"
|
|
echo ""
|
|
read -p "Enter your choice (1, 2, or q): " user_choice
|
|
|
|
case "$user_choice" in
|
|
1|"p"|"proceed")
|
|
log_info "Proceeding with current configuration..."
|
|
return 0
|
|
;;
|
|
2|"r"|"review"|"change")
|
|
user_wants_to_review=true
|
|
echo ""
|
|
echo "Reviewing all variables - press Enter to keep current value or enter new value:"
|
|
echo ""
|
|
;;
|
|
[Qq]|[Qq]uit)
|
|
log_info "Setup cancelled by user"
|
|
exit 0
|
|
;;
|
|
*)
|
|
log_warning "Invalid choice. Please enter 1, 2, or q."
|
|
echo ""
|
|
continue
|
|
;;
|
|
esac
|
|
else
|
|
echo ""
|
|
echo "Missing variables: ${missing_vars[*]}"
|
|
echo "Some variables need configuration. Press Enter to continue or 'q' to quit."
|
|
read -p "Press Enter to configure missing variables, or 'q' to quit: " user_choice
|
|
|
|
case "$user_choice" in
|
|
[Qq]|[Qq]uit)
|
|
log_info "Setup cancelled by user"
|
|
exit 0
|
|
;;
|
|
""|"c"|"continue")
|
|
# Continue with prompting
|
|
;;
|
|
*)
|
|
log_warning "Invalid choice. Press Enter to continue or 'q' to quit."
|
|
continue
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Prompt for variables (either missing ones or all if reviewing)
|
|
if [ "$user_wants_to_review" = true ]; then
|
|
# Review all variables one by one
|
|
for var in "${REQUIRED_VARS[@]}"; do
|
|
prompt_for_variable "$var"
|
|
done
|
|
# After review, continue the loop to show menu again
|
|
continue
|
|
else
|
|
# Only prompt for missing/invalid variables
|
|
for var in "${REQUIRED_VARS[@]}"; do
|
|
if [ -z "${!var:-}" ] || ! validate_variable "$var" "${!var}"; then
|
|
prompt_for_variable "$var"
|
|
fi
|
|
done
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Save configuration to .env file
|
|
save_env_file() {
|
|
debug_log "save_env_file() called, DEPLOY_CORE=$DEPLOY_CORE"
|
|
log_info "Saving configuration to .env file..."
|
|
|
|
# Create .env file if it doesn't exist
|
|
if [ ! -f "$REPO_DIR/.env" ]; then
|
|
sudo -u "$ACTUAL_USER" cp "$REPO_DIR/.env.example" "$REPO_DIR/.env"
|
|
fi
|
|
|
|
# Update only the required variables
|
|
for var in "${REQUIRED_VARS[@]}"; do
|
|
if [ -n "${!var:-}" ]; then
|
|
sudo -u "$ACTUAL_USER" sed -i "s|^${var}=.*|${var}=${!var}|" "$REPO_DIR/.env"
|
|
fi
|
|
done
|
|
|
|
# Update HOMEPAGE_ALLOWED_HOSTS dynamically
|
|
if [ -n "${DOMAIN:-}" ] && [ -n "${SERVER_IP:-}" ]; then
|
|
# Extract Homepage port from compose file
|
|
HOMEPAGE_PORT=$(grep -A1 'ports:' "$REPO_DIR/docker-compose/dashboards/docker-compose.yml" | grep -o '"[0-9]*:3000"' | cut -d'"' -f2 | cut -d: -f1)
|
|
if [ -z "$HOMEPAGE_PORT" ]; then
|
|
HOMEPAGE_PORT=3003 # Fallback
|
|
fi
|
|
HOMEPAGE_ALLOWED_HOSTS="homepage.${DOMAIN},${SERVER_IP}:${HOMEPAGE_PORT}"
|
|
sudo -u "$ACTUAL_USER" sed -i "s|HOMEPAGE_ALLOWED_HOSTS=.*|HOMEPAGE_ALLOWED_HOSTS=$HOMEPAGE_ALLOWED_HOSTS|" "$REPO_DIR/.env"
|
|
fi
|
|
|
|
# Authelia settings (only generate secrets if deploying core)
|
|
if [ "$DEPLOY_CORE" = true ]; then
|
|
# Ensure we have admin credentials
|
|
if [ -z "$ADMIN_USER" ]; then
|
|
ADMIN_USER="${DEFAULT_USER:-admin}"
|
|
fi
|
|
if [ -z "$ADMIN_EMAIL" ]; then
|
|
ADMIN_EMAIL="${DEFAULT_EMAIL:-${ADMIN_USER}@${DOMAIN}}"
|
|
fi
|
|
if [ -z "$AUTHELIA_ADMIN_PASSWORD" ]; then
|
|
AUTHELIA_ADMIN_PASSWORD="${DEFAULT_PASSWORD}"
|
|
if [ "$AUTHELIA_ADMIN_PASSWORD" = "changeme123" ]; then
|
|
log_info "Using default admin password (changeme123) - please change this after setup!"
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$AUTHELIA_JWT_SECRET" ]; then
|
|
AUTHELIA_JWT_SECRET=$(openssl rand -hex 64)
|
|
fi
|
|
if [ -z "$AUTHELIA_SESSION_SECRET" ]; then
|
|
AUTHELIA_SESSION_SECRET=$(openssl rand -hex 64)
|
|
fi
|
|
if [ -z "$AUTHELIA_STORAGE_ENCRYPTION_KEY" ]; then
|
|
AUTHELIA_STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 64)
|
|
fi
|
|
|
|
# Save Authelia settings to .env
|
|
sudo -u "$ACTUAL_USER" sed -i "s%AUTHELIA_JWT_SECRET=.*%AUTHELIA_JWT_SECRET=$AUTHELIA_JWT_SECRET%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%AUTHELIA_SESSION_SECRET=.*%AUTHELIA_SESSION_SECRET=$AUTHELIA_SESSION_SECRET%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%AUTHELIA_STORAGE_ENCRYPTION_KEY=.*%AUTHELIA_STORAGE_ENCRYPTION_KEY=$AUTHELIA_STORAGE_ENCRYPTION_KEY%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%# AUTHELIA_ADMIN_USER=.*%AUTHELIA_ADMIN_USER=$ADMIN_USER%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%AUTHELIA_ADMIN_USER=.*%AUTHELIA_ADMIN_USER=$ADMIN_USER%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%# AUTHELIA_ADMIN_EMAIL=.*%AUTHELIA_ADMIN_EMAIL=$ADMIN_EMAIL%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%AUTHELIA_ADMIN_EMAIL=.*%AUTHELIA_ADMIN_EMAIL=$ADMIN_EMAIL%" "$REPO_DIR/.env"
|
|
|
|
# Generate password hash if needed
|
|
if [ -z "$AUTHELIA_ADMIN_PASSWORD_HASH" ]; then
|
|
log_info "Generating Authelia password hash..."
|
|
# Pull Authelia image if needed
|
|
if ! docker images | grep -q authelia/authelia; then
|
|
docker pull authelia/authelia:latest > /dev/null 2>&1
|
|
fi
|
|
AUTHELIA_ADMIN_PASSWORD_HASH=$(docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password "$AUTHELIA_ADMIN_PASSWORD" 2>&1 | awk '/\$argon2id/ {print $NF}')
|
|
if [ -z "$AUTHELIA_ADMIN_PASSWORD_HASH" ]; then
|
|
log_error "Failed to generate Authelia password hash. Please check that AUTHELIA_ADMIN_PASSWORD is set."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Save password hash
|
|
sudo -u "$ACTUAL_USER" sed -i "s%# AUTHELIA_ADMIN_PASSWORD_HASH=.*%AUTHELIA_ADMIN_PASSWORD_HASH=$AUTHELIA_ADMIN_PASSWORD_HASH%" "$REPO_DIR/.env"
|
|
sudo -u "$ACTUAL_USER" sed -i "s%AUTHELIA_ADMIN_PASSWORD_HASH=.*%AUTHELIA_ADMIN_PASSWORD_HASH=$AUTHELIA_ADMIN_PASSWORD_HASH%" "$REPO_DIR/.env"
|
|
fi
|
|
|
|
debug_log "Configuration saved to .env file"
|
|
log_success "Configuration saved to .env file"
|
|
}
|
|
|
|
# Validate that required secrets are present for core deployment
|
|
validate_secrets() {
|
|
debug_log "validate_secrets called, DEPLOY_CORE=$DEPLOY_CORE"
|
|
|
|
if [ "$DEPLOY_CORE" = false ]; then
|
|
debug_log "Core not being deployed, skipping secret validation"
|
|
return 0
|
|
fi
|
|
|
|
log_info "Validating required secrets for core deployment..."
|
|
debug_log "Checking Authelia secrets..."
|
|
|
|
local missing_secrets=""
|
|
|
|
# Check required Authelia secrets
|
|
if [ -z "${AUTHELIA_JWT_SECRET:-}" ]; then
|
|
missing_secrets="$missing_secrets AUTHELIA_JWT_SECRET"
|
|
debug_log "AUTHELIA_JWT_SECRET is missing"
|
|
fi
|
|
|
|
if [ -z "${AUTHELIA_SESSION_SECRET:-}" ]; then
|
|
missing_secrets="$missing_secrets AUTHELIA_SESSION_SECRET"
|
|
debug_log "AUTHELIA_SESSION_SECRET is missing"
|
|
fi
|
|
|
|
if [ -z "${AUTHELIA_STORAGE_ENCRYPTION_KEY:-}" ]; then
|
|
missing_secrets="$missing_secrets AUTHELIA_STORAGE_ENCRYPTION_KEY"
|
|
debug_log "AUTHELIA_STORAGE_ENCRYPTION_KEY is missing"
|
|
fi
|
|
|
|
if [ -z "${AUTHELIA_ADMIN_PASSWORD_HASH:-}" ]; then
|
|
missing_secrets="$missing_secrets AUTHELIA_ADMIN_PASSWORD_HASH"
|
|
debug_log "AUTHELIA_ADMIN_PASSWORD_HASH is missing"
|
|
fi
|
|
|
|
# Check other required variables
|
|
if [ -z "${DOMAIN:-}" ]; then
|
|
missing_secrets="$missing_secrets DOMAIN"
|
|
debug_log "DOMAIN is missing"
|
|
fi
|
|
|
|
if [ -z "${SERVER_IP:-}" ]; then
|
|
missing_secrets="$missing_secrets SERVER_IP"
|
|
debug_log "SERVER_IP is missing"
|
|
fi
|
|
|
|
if [ -n "$missing_secrets" ]; then
|
|
log_error "Critical configuration missing: $missing_secrets"
|
|
log_error "This will prevent Authelia and other services from starting correctly."
|
|
debug_log "Failing deployment due to missing secrets: $missing_secrets"
|
|
exit 1
|
|
fi
|
|
|
|
log_success "All required secrets validated"
|
|
debug_log "Secret validation passed"
|
|
}
|
|
|
|
# Install NVIDIA drivers function
|
|
install_nvidia() {
|
|
log_info "Installing NVIDIA drivers and Docker support..."
|
|
|
|
# Check if running as root
|
|
if [ "$EUID" -ne 0 ]; then
|
|
log_warning "NVIDIA installation requires root privileges. Running with sudo..."
|
|
exec sudo "$0" "$@"
|
|
fi
|
|
|
|
# Check for NVIDIA GPU
|
|
if ! lspci | grep -i nvidia > /dev/null; then
|
|
log_warning "No NVIDIA GPU detected. Skipping NVIDIA driver installation."
|
|
return
|
|
fi
|
|
|
|
# Add NVIDIA repository
|
|
log_info "Adding NVIDIA repository..."
|
|
apt-get update
|
|
apt-get install -y software-properties-common
|
|
add-apt-repository -y ppa:graphics-drivers/ppa
|
|
apt-get update
|
|
|
|
# Install NVIDIA drivers (latest)
|
|
log_info "Installing NVIDIA drivers..."
|
|
apt-get install -y nvidia-driver-470 # Adjust version as needed
|
|
|
|
# Install NVIDIA Docker support
|
|
log_info "Installing NVIDIA Docker support..."
|
|
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
|
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | apt-key add -
|
|
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | tee /etc/apt/sources.list.d/nvidia-docker.list
|
|
apt-get update && apt-get install -y nvidia-docker2
|
|
systemctl restart docker
|
|
|
|
log_success "NVIDIA drivers and Docker support installed. A reboot may be required."
|
|
}
|
|
|
|
# Deploy Dockge function
|
|
deploy_dockge() {
|
|
log_info "Deploying Dockge stack manager..."
|
|
log_info " - Dockge (Docker Compose Manager)"
|
|
echo ""
|
|
|
|
# Copy Dockge stack files
|
|
sudo cp "$REPO_DIR/docker-compose/dockge/docker-compose.yml" /opt/dockge/docker-compose.yml
|
|
sudo cp "$REPO_DIR/.env" /opt/dockge/.env
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/dockge/docker-compose.yml
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/dockge/.env
|
|
|
|
# Remove sensitive variables from dockge .env (Dockge doesn't need them)
|
|
sed -i '/^AUTHELIA_ADMIN_PASSWORD_HASH=/d' /opt/dockge/.env
|
|
sed -i '/^AUTHELIA_JWT_SECRET=/d' /opt/dockge/.env
|
|
sed -i '/^AUTHELIA_SESSION_SECRET=/d' /opt/dockge/.env
|
|
sed -i '/^AUTHELIA_STORAGE_ENCRYPTION_KEY=/d' /opt/dockge/.env
|
|
|
|
# Replace placeholders in Dockge compose file
|
|
localize_yml_file "/opt/dockge/docker-compose.yml"
|
|
|
|
# Deploy Dockge stack
|
|
cd /opt/dockge
|
|
run_cmd docker compose up -d || true
|
|
log_success "Dockge deployed"
|
|
echo ""
|
|
}
|
|
|
|
# Deploy core stack function
|
|
deploy_core() {
|
|
debug_log "deploy_core called"
|
|
log_info "Deploying core stack..."
|
|
log_info " - DuckDNS (Dynamic DNS)"
|
|
log_info " - Traefik (Reverse Proxy with SSL)"
|
|
log_info " - Authelia (Single Sign-On)"
|
|
echo ""
|
|
|
|
# Copy core stack files
|
|
debug_log "Copying core stack files"
|
|
sudo cp "$REPO_DIR/docker-compose/core/docker-compose.yml" /opt/stacks/core/docker-compose.yml
|
|
sudo cp "$REPO_DIR/.env" /opt/stacks/core/.env
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/core/docker-compose.yml
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/core/.env
|
|
|
|
# Fix multi-line secrets in .env file (merge split lines)
|
|
debug_log "Fixing multi-line secrets in .env file"
|
|
python3 << 'PYFIX'
|
|
import sys
|
|
with open('/opt/stacks/core/.env', 'r') as f:
|
|
lines = f.readlines()
|
|
new_lines = []
|
|
i = 0
|
|
while i < len(lines):
|
|
if any(k in lines[i] for k in ['AUTHELIA_JWT_SECRET=', 'AUTHELIA_SESSION_SECRET=', 'AUTHELIA_STORAGE_ENCRYPTION_KEY=']):
|
|
if i + 1 < len(lines) and '=' not in lines[i+1] and lines[i+1].strip() and not lines[i+1].strip().startswith('#'):
|
|
new_lines.append(lines[i].rstrip('\n') + lines[i+1].lstrip())
|
|
i += 2
|
|
continue
|
|
new_lines.append(lines[i])
|
|
i += 1
|
|
with open('/opt/stacks/core/.env', 'w') as f:
|
|
f.writelines(new_lines)
|
|
PYFIX
|
|
|
|
# Escape $ characters in password hashes to prevent Docker Compose variable substitution
|
|
sed -i '/^AUTHELIA_ADMIN_PASSWORD_HASH=/ s/\$/\\$/g' /opt/stacks/core/.env
|
|
|
|
# Remove variables that core stack doesn't need
|
|
sed -i '/^QBITTORRENT_/d' /opt/stacks/core/.env
|
|
sed -i '/^GRAFANA_/d' /opt/stacks/core/.env
|
|
sed -i '/^CODE_SERVER_/d' /opt/stacks/core/.env
|
|
sed -i '/^JUPYTER_/d' /opt/stacks/core/.env
|
|
sed -i '/^POSTGRES_/d' /opt/stacks/core/.env
|
|
sed -i '/^NEXTCLOUD_/d' /opt/stacks/core/.env
|
|
sed -i '/^GITEA_/d' /opt/stacks/core/.env
|
|
sed -i '/^WORDPRESS_/d' /opt/stacks/core/.env
|
|
sed -i '/^BOOKSTACK_/d' /opt/stacks/core/.env
|
|
sed -i '/^MEDIAWIKI_/d' /opt/stacks/core/.env
|
|
sed -i '/^BITWARDEN_/d' /opt/stacks/core/.env
|
|
sed -i '/^FORMIO_/d' /opt/stacks/core/.env
|
|
sed -i '/^HOMEPAGE_VAR_/d' /opt/stacks/core/.env
|
|
|
|
# Replace placeholders in core compose file (fail on missing critical vars)
|
|
localize_yml_file "/opt/stacks/core/docker-compose.yml" true
|
|
|
|
# Copy and configure Traefik config
|
|
debug_log "Setting up Traefik configuration"
|
|
if [ -d "/opt/stacks/core/traefik" ]; then
|
|
mv /opt/stacks/core/traefik /opt/stacks/core/traefik.backup.$(date +%y_%m_%d_%H_%M)
|
|
fi
|
|
cp -r "$REPO_DIR/docker-compose/core/traefik" /opt/stacks/core/
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/core/traefik
|
|
|
|
# Move Traefik config file to the correct location for Docker mount
|
|
debug_log "Moving Traefik config file to config directory"
|
|
mkdir -p /opt/stacks/core/traefik/config
|
|
mv /opt/stacks/core/traefik/traefik.yml /opt/stacks/core/traefik/config/
|
|
|
|
# Only copy external host files on core server (where Traefik runs)
|
|
if [ "$DEPLOY_CORE" = true ]; then
|
|
log_info "Core server detected - copying external host routing files"
|
|
# Remove local-host-production.yml if no remote server hostname is set (single-server setup)
|
|
if [ -z "${REMOTE_SERVER_HOSTNAME:-}" ]; then
|
|
rm -f /opt/stacks/core/traefik/dynamic/local-host-production.yml
|
|
# Remove remote server sections from sablier.yml for single-server setup
|
|
sed -i '335,$d' /opt/stacks/core/traefik/dynamic/sablier.yml
|
|
log_info "Single-server setup - removed remote server sections from sablier.yml"
|
|
fi
|
|
else
|
|
log_info "Remote server detected - removing external host routing files"
|
|
rm -f /opt/stacks/core/traefik/dynamic/external-host-*.yml
|
|
fi
|
|
|
|
# Replace all placeholders in Traefik config files
|
|
debug_log "Replacing placeholders in Traefik config files"
|
|
for config_file in $(find /opt/stacks/core/traefik -name "*.yml" -type f); do
|
|
# Don't fail on missing variables for external host files (they're optional)
|
|
if [[ "$config_file" == *external-host* ]]; then
|
|
localize_yml_file "$config_file" false
|
|
else
|
|
localize_yml_file "$config_file" true
|
|
fi
|
|
done
|
|
|
|
# Rename external-host-production.yml to use remote server hostname (only for multi-server setups)
|
|
if [ -n "${REMOTE_SERVER_HOSTNAME:-}" ] && [ -f "/opt/stacks/core/traefik/dynamic/external-host-production.yml" ]; then
|
|
mv "/opt/stacks/core/traefik/dynamic/external-host-production.yml" "/opt/stacks/core/traefik/dynamic/external-host-${REMOTE_SERVER_HOSTNAME}.yml"
|
|
log_info "Renamed external-host-production.yml to external-host-${REMOTE_SERVER_HOSTNAME}.yml"
|
|
fi
|
|
|
|
# Copy and configure Authelia config
|
|
debug_log "Setting up Authelia configuration"
|
|
if [ -d "/opt/stacks/core/authelia" ]; then
|
|
mv /opt/stacks/core/authelia /opt/stacks/core/authelia.backup.$(date +%y_%m_%d_%H_%M)
|
|
fi
|
|
cp -r "$REPO_DIR/docker-compose/core/authelia" /opt/stacks/core/
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/core/authelia
|
|
|
|
# Replace all placeholders in Authelia config files
|
|
debug_log "Replacing placeholders in Authelia config files"
|
|
for config_file in $(find /opt/stacks/core/authelia -name "*.yml" -type f); do
|
|
if [[ "$config_file" == *"users_database.yml" ]]; then
|
|
localize_users_database_file "$config_file"
|
|
else
|
|
localize_yml_file "$config_file" true
|
|
fi
|
|
done
|
|
|
|
# Remove invalid session.cookies section from Authelia config (not supported in v4.37.5)
|
|
debug_log "Removing invalid session.cookies section from Authelia config"
|
|
sed -i '/^ cookies:/,/^$/d' /opt/stacks/core/authelia/config/configuration.yml
|
|
|
|
# Ensure proper ownership of Authelia files
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/core/authelia
|
|
|
|
# Generate shared CA for multi-server TLS
|
|
debug_log "Generating shared CA"
|
|
log_info "Generating shared CA certificate for multi-server TLS..."
|
|
generate_shared_ca
|
|
|
|
# Deploy core stack
|
|
debug_log "Deploying core stack with docker compose"
|
|
cd /opt/stacks/core
|
|
run_cmd docker compose up -d || true
|
|
log_success "Core infrastructure deployed"
|
|
echo ""
|
|
|
|
# Deploy Sablier stack for lazy loading
|
|
log_info "Deploying Sablier stack for lazy loading..."
|
|
deploy_sablier_stack
|
|
echo ""
|
|
}
|
|
|
|
# Deploy infrastructure stack function
|
|
deploy_infrastructure() {
|
|
log_info "Deploying infrastructure stack..."
|
|
log_info " - Pi-hole (DNS Ad Blocker)"
|
|
log_info " - Watchtower (Container Updates)"
|
|
log_info " - Dozzle (Log Viewer)"
|
|
log_info " - Glances (System Monitor)"
|
|
log_info " - Docker Proxy (Security)"
|
|
echo ""
|
|
|
|
# Copy infrastructure stack
|
|
cp "$REPO_DIR/docker-compose/infrastructure/docker-compose.yml" /opt/stacks/infrastructure/docker-compose.yml
|
|
cp "$REPO_DIR/.env" /opt/stacks/infrastructure/.env
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/infrastructure/docker-compose.yml
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/infrastructure/.env
|
|
|
|
# Remove variables that infrastructure stack doesn't need
|
|
sed -i '/^AUTHELIA_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^QBITTORRENT_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^GRAFANA_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^CODE_SERVER_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^JUPYTER_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^POSTGRES_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^NEXTCLOUD_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^GITEA_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^WORDPRESS_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^BOOKSTACK_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^MEDIAWIKI_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^BITWARDEN_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^FORMIO_/d' /opt/stacks/infrastructure/.env
|
|
sed -i '/^HOMEPAGE_VAR_/d' /opt/stacks/infrastructure/.env
|
|
|
|
# Replace placeholders in infrastructure compose file
|
|
localize_yml_file "/opt/stacks/infrastructure/docker-compose.yml"
|
|
|
|
# Copy any additional config directories
|
|
for config_dir in "$REPO_DIR/docker-compose/infrastructure"/*/; do
|
|
if [ -d "$config_dir" ] && [ "$(basename "$config_dir")" != "." ]; then
|
|
cp -r "$config_dir" /opt/stacks/infrastructure/
|
|
fi
|
|
done
|
|
|
|
# If core is not deployed, remove Authelia middleware references
|
|
if [ "$DEPLOY_CORE" = false ]; then
|
|
log_info "Core infrastructure not deployed - removing Authelia middleware references..."
|
|
sed -i '/middlewares=authelia@docker/d' /opt/stacks/infrastructure/docker-compose.yml
|
|
fi
|
|
|
|
# Replace placeholders in infrastructure compose file
|
|
localize_yml_file "/opt/stacks/infrastructure/docker-compose.yml"
|
|
|
|
# Deploy infrastructure stack
|
|
cd /opt/stacks/infrastructure
|
|
run_cmd docker compose up -d || true
|
|
log_success "Infrastructure stack deployed"
|
|
echo ""
|
|
}
|
|
|
|
# Deploy dashboards stack function
|
|
deploy_dashboards() {
|
|
log_info "Deploying dashboard stack..."
|
|
log_info " - Homepage (Application Dashboard)"
|
|
log_info " - Homarr (Modern Dashboard)"
|
|
echo ""
|
|
|
|
# Create dashboards directory
|
|
sudo mkdir -p /opt/stacks/dashboards
|
|
|
|
# Copy dashboards compose file
|
|
cp "$REPO_DIR/docker-compose/dashboards/docker-compose.yml" /opt/stacks/dashboards/docker-compose.yml
|
|
cp "$REPO_DIR/.env" /opt/stacks/dashboards/.env
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/dashboards/docker-compose.yml
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/dashboards/.env
|
|
|
|
# Remove variables that dashboards stack doesn't need
|
|
sed -i '/^AUTHELIA_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^QBITTORRENT_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^CODE_SERVER_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^JUPYTER_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^POSTGRES_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^NEXTCLOUD_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^GITEA_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^WORDPRESS_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^BOOKSTACK_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^MEDIAWIKI_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^BITWARDEN_/d' /opt/stacks/dashboards/.env
|
|
sed -i '/^FORMIO_/d' /opt/stacks/dashboards/.env
|
|
|
|
# Replace placeholders in dashboards compose file
|
|
localize_yml_file "/opt/stacks/dashboards/docker-compose.yml"
|
|
|
|
# Copy homepage config
|
|
if [ -d "$REPO_DIR/docker-compose/dashboards/homepage" ]; then
|
|
cp -r "$REPO_DIR/docker-compose/dashboards/homepage" /opt/stacks/dashboards/
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks/dashboards/homepage
|
|
|
|
# Replace placeholders in homepage config files
|
|
find /opt/stacks/dashboards/homepage -name "*.yaml" -type f | while read -r config_file; do
|
|
localize_yml_file "$config_file"
|
|
done
|
|
|
|
# Remove remote server entries from homepage services for single-server setup
|
|
if [ -z "${REMOTE_SERVER_HOSTNAME:-}" ]; then
|
|
sed -i '/\${REMOTE_SERVER_HOSTNAME}/d' /opt/stacks/dashboards/homepage/services.yaml
|
|
log_info "Single-server setup - removed remote server entries from homepage services"
|
|
fi
|
|
|
|
# Process template files and rename them
|
|
find /opt/stacks/dashboards/homepage -name "*.template" -type f | while read -r template_file; do
|
|
localize_yml_file "$template_file"
|
|
# Rename template file to remove .template extension
|
|
new_file="${template_file%.template}"
|
|
mv "$template_file" "$new_file"
|
|
log_info "Processed and renamed $template_file to $new_file"
|
|
done
|
|
fi
|
|
|
|
# Replace placeholders in dashboards compose file
|
|
localize_yml_file "/opt/stacks/dashboards/docker-compose.yml"
|
|
|
|
# Deploy dashboards stack
|
|
cd /opt/stacks/dashboards
|
|
run_cmd docker compose up -d || true
|
|
log_success "Dashboard stack deployed"
|
|
echo ""
|
|
}
|
|
|
|
# Deployment function
|
|
perform_deployment() {
|
|
debug_log "perform_deployment() called with DEPLOY_CORE=$DEPLOY_CORE, DEPLOY_INFRASTRUCTURE=$DEPLOY_INFRASTRUCTURE, DEPLOY_DASHBOARDS=$DEPLOY_DASHBOARDS, SETUP_STACKS=$SETUP_STACKS"
|
|
log_info "Starting deployment..."
|
|
|
|
# Initialize missing vars summary
|
|
GLOBAL_MISSING_VARS=""
|
|
TLS_ISSUES_SUMMARY=""
|
|
|
|
# Switch back to regular user if we were running as root
|
|
if [ "$EUID" -eq 0 ]; then
|
|
ACTUAL_USER=${SUDO_USER:-$USER}
|
|
debug_log "Running as root, switching to user $ACTUAL_USER"
|
|
log_info "Switching to user $ACTUAL_USER for deployment..."
|
|
exec sudo -u "$ACTUAL_USER" "$0" "$@"
|
|
fi
|
|
|
|
# Source the .env file safely
|
|
debug_log "Sourcing .env file from $REPO_DIR/.env"
|
|
load_env_file_safely "$REPO_DIR/.env"
|
|
debug_log "Environment loaded, DOMAIN=$DOMAIN, SERVER_IP=$SERVER_IP"
|
|
|
|
# Generate Authelia password hash if needed
|
|
if [ "$AUTHELIA_ADMIN_PASSWORD_HASH" = "generate-with-openssl-rand-hex-64" ] || [ -z "$AUTHELIA_ADMIN_PASSWORD_HASH" ]; then
|
|
log_info "Generating Authelia password hash..."
|
|
if ! docker images | grep -q authelia/authelia; then
|
|
docker pull authelia/authelia:latest > /dev/null 2>&1
|
|
fi
|
|
AUTHELIA_ADMIN_PASSWORD_HASH=$(docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password "$DEFAULT_PASSWORD" 2>&1 | awk '/\$argon2id/ {print $NF}')
|
|
if [ -z "$AUTHELIA_ADMIN_PASSWORD_HASH" ]; then
|
|
log_error "Failed to generate Authelia password hash."
|
|
exit 1
|
|
fi
|
|
# Save it back to .env
|
|
sed -i "s%AUTHELIA_ADMIN_PASSWORD_HASH=.*%AUTHELIA_ADMIN_PASSWORD_HASH=\"$AUTHELIA_ADMIN_PASSWORD_HASH\"%" "$REPO_DIR/.env"
|
|
log_success "Authelia password hash generated and saved"
|
|
fi
|
|
|
|
# Reload .env to get updated secrets
|
|
load_env_file_safely "$REPO_DIR/.env"
|
|
|
|
# Step 1: Create required directories
|
|
log_info "Step 1: Creating required directories..."
|
|
sudo mkdir -p /opt/stacks/core || { log_error "Failed to create /opt/stacks/core"; exit 1; }
|
|
sudo mkdir -p /opt/stacks/infrastructure || { log_error "Failed to create /opt/stacks/infrastructure"; exit 1; }
|
|
sudo mkdir -p /opt/stacks/dashboards || { log_error "Failed to create /opt/stacks/dashboards"; exit 1; }
|
|
sudo mkdir -p /opt/dockge || { log_error "Failed to create /opt/dockge"; exit 1; }
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/dockge
|
|
log_success "Directories created"
|
|
|
|
# Step 2: Setup multi-server TLS if needed
|
|
if [ "$DEPLOY_CORE" = false ]; then
|
|
setup_multi_server_tls
|
|
fi
|
|
|
|
# Step 3: Create Docker networks (if they don't exist)
|
|
log_info "Step $([ "$DEPLOY_CORE" = false ] && echo "3" || echo "2"): Creating Docker networks..."
|
|
docker network create homelab-network 2>/dev/null && log_success "Created homelab-network" || log_info "homelab-network already exists"
|
|
docker network create traefik-network 2>/dev/null && log_success "Created traefik-network" || log_info "traefik-network already exists"
|
|
docker network create media-network 2>/dev/null && log_success "Created media-network" || log_info "media-network already exists"
|
|
echo ""
|
|
|
|
# Step 4: Deploy Dockge (always deployed)
|
|
deploy_dockge
|
|
|
|
# Deploy core stack
|
|
if [ "$DEPLOY_CORE" = true ]; then
|
|
deploy_core
|
|
fi
|
|
|
|
# Deploy infrastructure stack
|
|
if [ "$DEPLOY_INFRASTRUCTURE" = true ]; then
|
|
step_num=$([ "$DEPLOY_CORE" = true ] && echo "6" || echo "5")
|
|
deploy_infrastructure
|
|
fi
|
|
|
|
# Deploy dashboard stack
|
|
if [ "$DEPLOY_DASHBOARDS" = true ]; then
|
|
if [ "$DEPLOY_CORE" = true ] && [ "$DEPLOY_INFRASTRUCTURE" = true ]; then
|
|
step_num=7
|
|
elif [ "$DEPLOY_CORE" = true ] || [ "$DEPLOY_INFRASTRUCTURE" = true ]; then
|
|
step_num=6
|
|
else
|
|
step_num=5
|
|
fi
|
|
deploy_dashboards
|
|
fi
|
|
|
|
# Setup stacks for Dockge
|
|
if [ "$SETUP_STACKS" = true ]; then
|
|
setup_stacks_for_dockge
|
|
fi
|
|
|
|
# Report any missing variables
|
|
if [ -n "$GLOBAL_MISSING_VARS" ]; then
|
|
log_warning "The following environment variables were missing and may cause issues:"
|
|
echo "$GLOBAL_MISSING_VARS"
|
|
log_info "Please update your .env file and redeploy affected stacks."
|
|
fi
|
|
|
|
# TLS issues will be reported in the final summary
|
|
}
|
|
|
|
# Setup Docker TLS function
|
|
setup_docker_tls() {
|
|
local TLS_DIR="/home/$ACTUAL_USER/EZ-Homelab/docker-tls"
|
|
|
|
# Check if TLS is already configured
|
|
if [ -f "/etc/docker/daemon.json" ] && grep -q '"tls": true' /etc/docker/daemon.json 2>/dev/null; then
|
|
if systemctl cat docker.service | grep -q 'tcp://0.0.0.0:2376'; then
|
|
log_info "Docker TLS already configured, skipping..."
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Create TLS directory
|
|
sudo mkdir -p "$TLS_DIR"
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$TLS_DIR"
|
|
|
|
# Use shared CA if available, otherwise generate local CA
|
|
if [ -f "/opt/stacks/core/shared-ca/ca.pem" ] && [ -f "/opt/stacks/core/shared-ca/ca-key.pem" ]; then
|
|
log_info "Using shared CA certificate for Docker TLS..."
|
|
cp "/opt/stacks/core/shared-ca/ca.pem" "$TLS_DIR/ca.pem"
|
|
cp "/opt/stacks/core/shared-ca/ca-key.pem" "$TLS_DIR/ca-key.pem"
|
|
else
|
|
log_info "Generating local CA certificate for Docker TLS..."
|
|
# Generate CA
|
|
openssl genrsa -out "$TLS_DIR/ca-key.pem" 4096
|
|
openssl req -new -x509 -days 365 -key "$TLS_DIR/ca-key.pem" -sha256 -out "$TLS_DIR/ca.pem" -subj "/C=US/ST=State/L=City/O=Organization/CN=Docker-CA"
|
|
fi
|
|
|
|
# Generate server key and cert
|
|
openssl genrsa -out "$TLS_DIR/server-key.pem" 4096
|
|
openssl req -subj "/CN=$SERVER_IP" -new -key "$TLS_DIR/server-key.pem" -out "$TLS_DIR/server.csr"
|
|
echo "subjectAltName = DNS:$SERVER_IP,IP:$SERVER_IP,IP:127.0.0.1" > "$TLS_DIR/extfile.cnf"
|
|
openssl x509 -req -days 365 -in "$TLS_DIR/server.csr" -CA "$TLS_DIR/ca.pem" -CAkey "$TLS_DIR/ca-key.pem" -CAcreateserial -out "$TLS_DIR/server-cert.pem" -extfile "$TLS_DIR/extfile.cnf"
|
|
|
|
# Generate client key and cert
|
|
openssl genrsa -out "$TLS_DIR/client-key.pem" 4096
|
|
openssl req -subj "/CN=client" -new -key "$TLS_DIR/client-key.pem" -out "$TLS_DIR/client.csr"
|
|
openssl x509 -req -days 365 -in "$TLS_DIR/client.csr" -CA "$TLS_DIR/ca.pem" -CAkey "$TLS_DIR/ca-key.pem" -CAcreateserial -out "$TLS_DIR/client-cert.pem"
|
|
|
|
# Configure Docker daemon
|
|
sudo tee /etc/docker/daemon.json > /dev/null <<EOF
|
|
{
|
|
"tls": true,
|
|
"tlsverify": true,
|
|
"tlscacert": "$TLS_DIR/ca.pem",
|
|
"tlscert": "$TLS_DIR/server-cert.pem",
|
|
"tlskey": "$TLS_DIR/server-key.pem"
|
|
}
|
|
EOF
|
|
|
|
# Update systemd service only if not already configured (idempotent)
|
|
if ! systemctl cat docker.service | grep -q 'tcp://0.0.0.0:2376'; then
|
|
log_info "Adding TCP socket to Docker service..."
|
|
sudo sed -i 's|^ExecStart=/usr/bin/dockerd -H fd://|ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2376|' /lib/systemd/system/docker.service
|
|
else
|
|
# Clean up any duplicate TCP socket entries
|
|
if systemctl cat docker.service | grep -c 'tcp://0.0.0.0:2376' | grep -q '^[2-9]'; then
|
|
log_warning "Found duplicate TCP socket entries, cleaning up..."
|
|
# Extract the current ExecStart line and remove duplicates
|
|
local exec_start=$(systemctl cat docker.service | grep '^ExecStart=' | head -1)
|
|
local cleaned_exec=$(echo "$exec_start" | sed 's|-H tcp://0.0.0.0:2376||g')
|
|
cleaned_exec="${cleaned_exec} -H tcp://0.0.0.0:2376"
|
|
sudo sed -i "s|^ExecStart=.*|${cleaned_exec}|" /lib/systemd/system/docker.service
|
|
fi
|
|
fi
|
|
|
|
# Reload and restart Docker
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl restart docker
|
|
|
|
# Wait for Docker to be ready
|
|
sleep 3
|
|
if ! docker ps &>/dev/null; then
|
|
log_error "Docker failed to start after TLS configuration"
|
|
return 1
|
|
fi
|
|
|
|
log_success "Docker TLS configured on port 2376"
|
|
}
|
|
setup_stacks_for_dockge() {
|
|
log_info "Setting up all stacks for Dockge..."
|
|
|
|
# List of stacks to setup
|
|
STACKS=("vpn" "media" "media-management" "transcoders" "monitoring" "productivity" "wikis" "utilities" "alternatives" "homeassistant")
|
|
|
|
for stack in "${STACKS[@]}"; do
|
|
STACK_DIR="/opt/stacks/$stack"
|
|
REPO_STACK_DIR="$REPO_DIR/docker-compose/$stack"
|
|
|
|
if [ -d "$REPO_STACK_DIR" ]; then
|
|
mkdir -p "$STACK_DIR"
|
|
if [ -f "$REPO_STACK_DIR/docker-compose.yml" ]; then
|
|
cp "$REPO_STACK_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml"
|
|
cp "$REPO_DIR/.env" "$STACK_DIR/.env"
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$STACK_DIR/docker-compose.yml"
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$STACK_DIR/.env"
|
|
|
|
# Remove sensitive/unnecessary variables from stack .env
|
|
sed -i '/^AUTHELIA_ADMIN_PASSWORD_HASH=/d' "$STACK_DIR/.env"
|
|
sed -i '/^AUTHELIA_JWT_SECRET=/d' "$STACK_DIR/.env"
|
|
sed -i '/^AUTHELIA_SESSION_SECRET=/d' "$STACK_DIR/.env"
|
|
sed -i '/^AUTHELIA_STORAGE_ENCRYPTION_KEY=/d' "$STACK_DIR/.env"
|
|
sed -i '/^SURFSHARK_/d' "$STACK_DIR/.env"
|
|
sed -i '/^SMTP_/d' "$STACK_DIR/.env"
|
|
sed -i '/^REMOTE_SERVER_/d' "$STACK_DIR/.env"
|
|
sed -i '/^PIHOLE_/d' "$STACK_DIR/.env"
|
|
sed -i '/^WATCHTOWER_/d' "$STACK_DIR/.env"
|
|
sed -i '/^QBITTORRENT_/d' "$STACK_DIR/.env"
|
|
sed -i '/^GRAFANA_/d' "$STACK_DIR/.env"
|
|
sed -i '/^CODE_SERVER_/d' "$STACK_DIR/.env"
|
|
sed -i '/^JUPYTER_/d' "$STACK_DIR/.env"
|
|
sed -i '/^POSTGRES_/d' "$STACK_DIR/.env"
|
|
sed -i '/^PGADMIN_/d' "$STACK_DIR/.env"
|
|
sed -i '/^NEXTCLOUD_/d' "$STACK_DIR/.env"
|
|
sed -i '/^GITEA_/d' "$STACK_DIR/.env"
|
|
sed -i '/^WORDPRESS_/d' "$STACK_DIR/.env"
|
|
sed -i '/^BOOKSTACK_/d' "$STACK_DIR/.env"
|
|
sed -i '/^MEDIAWIKI_/d' "$STACK_DIR/.env"
|
|
sed -i '/^BITWARDEN_/d' "$STACK_DIR/.env"
|
|
sed -i '/^FORMIO_/d' "$STACK_DIR/.env"
|
|
sed -i '/^HOMEPAGE_VAR_/d' "$STACK_DIR/.env"
|
|
|
|
# Replace placeholders in the compose file
|
|
localize_yml_file "$STACK_DIR/docker-compose.yml"
|
|
|
|
# Copy any additional config directories
|
|
for config_dir in "$REPO_STACK_DIR"/*/; do
|
|
if [ -d "$config_dir" ] && [ "$(basename "$config_dir")" != "." ]; then
|
|
cp -r "$config_dir" "$STACK_DIR/"
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" "$STACK_DIR/$(basename "$config_dir")"
|
|
|
|
# Replace placeholders in config files
|
|
find "$STACK_DIR/$(basename "$config_dir")" -name "*.yml" -o -name "*.yaml" | while read -r config_file; do
|
|
localize_yml_file "$config_file"
|
|
done
|
|
fi
|
|
done
|
|
|
|
log_success "Prepared $stack stack for Dockge"
|
|
else
|
|
log_warning "$stack stack docker-compose.yml not found, skipping..."
|
|
fi
|
|
else
|
|
log_warning "$stack stack directory not found in repo, skipping..."
|
|
fi
|
|
done
|
|
|
|
log_success "All stacks prepared for Dockge deployment"
|
|
echo ""
|
|
}
|
|
|
|
# Main menu
|
|
show_main_menu() {
|
|
echo ""
|
|
echo "╔═════════════════════════════════════════════════════════════╗"
|
|
echo "║ EZ-HOMELAB SETUP & DEPLOYMENT ║"
|
|
echo "║ ║"
|
|
echo "║ 1) Install Prerequisites ║"
|
|
echo "║ 2) Deploy Core Server ║"
|
|
echo "║ 3) Deploy Additional Server ║"
|
|
echo "║ 4) Install NVIDIA Drivers ║"
|
|
echo "║ ║"
|
|
echo "║ q) Quit ║"
|
|
echo "╚═════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
}
|
|
|
|
# =============================================
|
|
# MULTI-SERVER DEPLOYMENT FUNCTIONS
|
|
# =============================================
|
|
|
|
# Clean up orphaned processes (important for resource-constrained servers)
|
|
cleanup_orphaned_processes() {
|
|
debug_log "Cleaning up orphaned processes"
|
|
|
|
# Kill zombie processes by killing their parent if possible
|
|
local zombies=$(ps aux | awk '$8 ~ /Z/ {print $2}')
|
|
if [ -n "$zombies" ]; then
|
|
log_warning "Found zombie processes, attempting cleanup..."
|
|
for zombie_pid in $zombies; do
|
|
local parent_pid=$(ps -o ppid= -p $zombie_pid 2>/dev/null | xargs)
|
|
if [ -n "$parent_pid" ] && [ "$parent_pid" != "1" ]; then
|
|
debug_log "Killing parent process $parent_pid to clean up zombie $zombie_pid"
|
|
sudo kill -SIGCHLD $parent_pid 2>/dev/null || true
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Kill any stuck docker compose logs processes older than 1 hour
|
|
local old_compose_logs=$(ps aux | grep 'docker compose logs' | grep -v grep | awk '$10 ~ /[0-9]+:[0-9]+:[0-9]+/ && $10 !~ /00:0[0-5]/ {print $2}')
|
|
if [ -n "$old_compose_logs" ]; then
|
|
log_warning "Found long-running docker compose logs processes, cleaning up..."
|
|
for pid in $old_compose_logs; do
|
|
debug_log "Killing docker compose logs process $pid"
|
|
sudo kill -9 $pid 2>/dev/null || true
|
|
done
|
|
fi
|
|
|
|
log_success "Process cleanup complete"
|
|
}
|
|
|
|
# Check system resources (important for resource-constrained servers)
|
|
check_system_resources() {
|
|
debug_log "Checking system resources"
|
|
|
|
# Check available memory
|
|
local mem_available=$(free -m | awk '/^Mem:/ {print $7}')
|
|
local mem_total=$(free -m | awk '/^Mem:/ {print $2}')
|
|
local mem_percent=$((mem_available * 100 / mem_total))
|
|
|
|
if [ $mem_percent -lt 20 ]; then
|
|
log_warning "Low memory available: ${mem_available}MB of ${mem_total}MB (${mem_percent}%)"
|
|
log_info "Consider closing other applications before deployment"
|
|
else
|
|
log_success "Memory check passed: ${mem_available}MB available (${mem_percent}%)"
|
|
fi
|
|
|
|
# Check disk space
|
|
local disk_available=$(df -m / | awk 'NR==2 {print $4}')
|
|
local disk_total=$(df -m / | awk 'NR==2 {print $2}')
|
|
local disk_percent=$((disk_available * 100 / disk_total))
|
|
|
|
if [ $disk_percent -lt 10 ]; then
|
|
log_error "Critical: Low disk space available: ${disk_available}MB of ${disk_total}MB (${disk_percent}%)"
|
|
log_error "Deployment may fail. Please free up disk space."
|
|
return 1
|
|
elif [ $disk_percent -lt 20 ]; then
|
|
log_warning "Low disk space: ${disk_available}MB of ${disk_total}MB (${disk_percent}%)"
|
|
else
|
|
log_success "Disk space check passed: ${disk_available}MB available (${disk_percent}%)"
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Check if Docker is installed and accessible
|
|
check_docker_installed() {
|
|
debug_log "Checking if Docker is installed"
|
|
|
|
if ! command -v docker &> /dev/null; then
|
|
log_error "Docker is not installed on this system"
|
|
log_info "Please run Option 1 (Install Prerequisites) first"
|
|
return 1
|
|
fi
|
|
|
|
if ! docker ps &> /dev/null; then
|
|
log_error "Docker is installed but not accessible"
|
|
log_info "Current user may not be in docker group. Try logging out and back in."
|
|
return 1
|
|
fi
|
|
|
|
debug_log "Docker is installed and accessible"
|
|
return 0
|
|
}
|
|
|
|
# Set required variables based on deployment type
|
|
set_required_vars_for_deployment() {
|
|
local deployment_type="$1"
|
|
debug_log "Setting required vars for deployment type: $deployment_type"
|
|
|
|
case "$deployment_type" in
|
|
"core")
|
|
REQUIRED_VARS=("SERVER_IP" "SERVER_HOSTNAME" "DUCKDNS_SUBDOMAINS" "DUCKDNS_TOKEN" "DOMAIN" "DEFAULT_USER" "DEFAULT_PASSWORD" "DEFAULT_EMAIL")
|
|
debug_log "Set REQUIRED_VARS for core deployment"
|
|
;;
|
|
"remote")
|
|
REQUIRED_VARS=("SERVER_IP" "SERVER_HOSTNAME" "DOMAIN" "DEFAULT_USER" "CORE_SERVER_IP" "CORE_SERVER_HOSTNAME" "CORE_SERVER_USER" "CORE_SERVER_PASSWORD")
|
|
debug_log "Set REQUIRED_VARS for remote deployment"
|
|
;;
|
|
*)
|
|
log_error "Unknown deployment type: $deployment_type"
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Deploy remote server
|
|
deploy_remote_server() {
|
|
# Enable verbose mode for remote deployment
|
|
VERBOSE=true
|
|
|
|
log_info "Deploying Remote Server Configuration"
|
|
echo ""
|
|
|
|
# Set ACTUAL_USER if not already set (needed for SSH key paths)
|
|
if [ -z "$ACTUAL_USER" ]; then
|
|
if [ "$EUID" -eq 0 ]; then
|
|
ACTUAL_USER=${SUDO_USER:-$USER}
|
|
else
|
|
ACTUAL_USER=$USER
|
|
fi
|
|
export ACTUAL_USER
|
|
debug_log "Set ACTUAL_USER=$ACTUAL_USER"
|
|
fi
|
|
|
|
# Clean up any orphaned processes before starting (important for resource-constrained servers)
|
|
cleanup_orphaned_processes
|
|
|
|
# Check system resources
|
|
check_system_resources
|
|
|
|
# Check Docker is installed
|
|
if ! check_docker_installed; then
|
|
log_error "Docker must be installed before deploying remote server"
|
|
return 1
|
|
fi
|
|
|
|
# Ensure we have core server information
|
|
if [ -z "$CORE_SERVER_IP" ] || [ -z "$CORE_SERVER_HOSTNAME" ]; then
|
|
log_error "Core server IP and hostname are required"
|
|
return 1
|
|
fi
|
|
|
|
# Step 1: Setup SSH key authentication to core server
|
|
log_info "Step 1: Setting up SSH key authentication to core server..."
|
|
log_info "Using ACTUAL_USER=$ACTUAL_USER for SSH key path"
|
|
log_info "Target: ${CORE_SERVER_USER}@${CORE_SERVER_IP}"
|
|
if ! setup_ssh_key_to_core; then
|
|
log_error "Failed to setup SSH key authentication"
|
|
return 1
|
|
fi
|
|
log_success "SSH key authentication setup complete"
|
|
echo ""
|
|
|
|
# Step 2: Create required Docker networks
|
|
log_info "Step 2: Creating required Docker networks..."
|
|
docker network create homelab-network 2>/dev/null && log_success "Created homelab-network" || log_info "homelab-network already exists"
|
|
docker network create traefik-network 2>/dev/null && log_success "Created traefik-network" || log_info "traefik-network already exists"
|
|
echo ""
|
|
|
|
# Step 3: Install envsubst if not present
|
|
if ! command -v envsubst &> /dev/null; then
|
|
log_info "Installing envsubst (gettext-base)..."
|
|
sudo apt-get update -qq && sudo apt-get install -y gettext-base >/dev/null 2>&1
|
|
log_success "envsubst installed"
|
|
fi
|
|
|
|
# Step 4: Copy all stacks to remote server
|
|
log_info "Step 4: Copying all stacks to remote server..."
|
|
copy_all_stacks_for_remote
|
|
echo ""
|
|
|
|
# Step 5: Configure services for additional server (remove Traefik labels)
|
|
log_info "Step 5: Configuring services for additional server..."
|
|
configure_remote_server_routing
|
|
echo ""
|
|
|
|
# Step 6: Deploy Dockge
|
|
log_info "Step 6: Deploying Dockge..."
|
|
deploy_dockge
|
|
echo ""
|
|
|
|
# Step 7: Deploy Sablier stack for local lazy loading
|
|
log_info "Step 7: Deploying Sablier stack..."
|
|
deploy_sablier_stack
|
|
echo ""
|
|
|
|
# Step 8: Deploy Infrastructure stack
|
|
log_info "Step 8: Deploying Infrastructure stack..."
|
|
deploy_infrastructure
|
|
echo ""
|
|
|
|
# Step 9: Register this remote server with core Traefik
|
|
log_info "Step 9: Registering with core Traefik..."
|
|
register_remote_server_with_core
|
|
echo ""
|
|
|
|
log_success "Remote server deployment complete!"
|
|
echo ""
|
|
echo "This server is now configured to:"
|
|
echo " - Run Dockge for local stack management"
|
|
echo " - Run Sablier for local container lazy loading"
|
|
echo " - Run infrastructure services with exposed ports"
|
|
echo " - Be accessible via core Traefik routes"
|
|
echo ""
|
|
echo "Services deployed on this server are accessible at:"
|
|
echo " - Via core Traefik: https://servicename.${SERVER_HOSTNAME}.${DOMAIN}"
|
|
echo " - Via direct IP: http://${SERVER_IP}:PORT"
|
|
echo ""
|
|
echo "Additional stacks available in /opt/stacks/ (not started):"
|
|
echo " - dashboards, media, media-management, monitoring, productivity"
|
|
echo " - transcoders, utilities, vpn, wikis, homeassistant, alternatives"
|
|
echo ""
|
|
}
|
|
|
|
# Register remote server with core Traefik
|
|
register_remote_server_with_core() {
|
|
debug_log "Registering remote server with core Traefik via SSH"
|
|
|
|
local key_name="id_rsa_${SERVER_HOSTNAME}_to_core"
|
|
local key_path="/home/$ACTUAL_USER/.ssh/$key_name"
|
|
|
|
if [ -z "$CORE_SERVER_IP" ] || [ -z "$CORE_SERVER_USER" ]; then
|
|
log_error "CORE_SERVER_IP and CORE_SERVER_USER are required"
|
|
return 1
|
|
fi
|
|
|
|
# Verify SSH key exists
|
|
if [ ! -f "$key_path" ]; then
|
|
log_error "SSH key not found: $key_path"
|
|
log_error "Please ensure setup_ssh_key_to_core() completed successfully"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Connecting to core server to register this remote server..."
|
|
log_info "Using key: $key_path"
|
|
|
|
# Test SSH connection first
|
|
if ! LC_ALL=C ssh -i "$key_path" -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}" "echo 'test'" 2>&1 | grep -q "test"; then
|
|
log_error "Cannot establish SSH connection to core server"
|
|
log_error "Please verify:"
|
|
echo " 1. SSH key is installed: ssh -i $key_path ${CORE_SERVER_USER}@${CORE_SERVER_IP}"
|
|
echo " 2. Core server is reachable: ping ${CORE_SERVER_IP}"
|
|
echo " 3. SSH service is running on core server"
|
|
return 1
|
|
fi
|
|
|
|
log_success "SSH connection verified"
|
|
|
|
# SSH to core server and run registration function
|
|
log_info "Running registration commands on core server..."
|
|
local ssh_output=$(LC_ALL=C ssh -i "$key_path" -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o LogLevel=ERROR \
|
|
"${CORE_SERVER_USER}@${CORE_SERVER_IP}" bash <<EOF 2>&1
|
|
# Source common.sh to get registration function
|
|
if [ -f ~/EZ-Homelab/scripts/common.sh ]; then
|
|
source ~/EZ-Homelab/scripts/common.sh
|
|
else
|
|
echo "ERROR: common.sh not found"
|
|
exit 1
|
|
fi
|
|
|
|
# Register this remote server
|
|
add_remote_server_to_traefik "${SERVER_IP}" "${SERVER_HOSTNAME}"
|
|
|
|
# Verify files were created
|
|
if [ -f "/opt/stacks/core/traefik/dynamic/docker-provider-${SERVER_HOSTNAME}.yml" ]; then
|
|
echo "SUCCESS: docker-provider file created"
|
|
else
|
|
echo "ERROR: docker-provider file not created"
|
|
exit 1
|
|
fi
|
|
|
|
if [ -f "/opt/stacks/core/traefik/dynamic/sablier-middleware-${SERVER_HOSTNAME}.yml" ]; then
|
|
echo "SUCCESS: sablier-middleware file created"
|
|
else
|
|
echo "ERROR: sablier-middleware file not created"
|
|
exit 1
|
|
fi
|
|
|
|
# Restart Traefik to reload configs
|
|
cd /opt/stacks/core
|
|
docker compose restart traefik
|
|
|
|
echo "SUCCESS: Registration complete"
|
|
EOF
|
|
)
|
|
|
|
local ssh_exit_code=$?
|
|
|
|
# Show output for debugging
|
|
echo "$ssh_output" | grep -v "locale\|LC_ALL\|setlocale"
|
|
|
|
if [ $ssh_exit_code -eq 0 ] && echo "$ssh_output" | grep -q "SUCCESS: Registration complete"; then
|
|
log_success "Successfully registered with core server"
|
|
log_info "Files created on core server:"
|
|
echo " - /opt/stacks/core/traefik/dynamic/docker-provider-${SERVER_HOSTNAME}.yml"
|
|
echo " - /opt/stacks/core/traefik/dynamic/sablier-middleware-${SERVER_HOSTNAME}.yml"
|
|
return 0
|
|
else
|
|
log_error "Failed to register with core server via SSH"
|
|
log_error "SSH output:"
|
|
echo "$ssh_output"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Deploy Sablier stack
|
|
deploy_sablier_stack() {
|
|
debug_log "Deploying Sablier stack"
|
|
|
|
local sablier_dir="/opt/stacks/sablier"
|
|
|
|
# Create sablier stack directory with sudo
|
|
if [ ! -d "$sablier_dir" ]; then
|
|
sudo mkdir -p "$sablier_dir" || { log_error "Failed to create $sablier_dir"; return 1; }
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" "$sablier_dir"
|
|
log_success "Created $sablier_dir"
|
|
fi
|
|
|
|
# Check if source files exist
|
|
if [ ! -f "$REPO_DIR/docker-compose/sablier/docker-compose.yml" ]; then
|
|
log_error "Sablier docker-compose.yml not found in repo at $REPO_DIR/docker-compose/sablier/"
|
|
return 1
|
|
fi
|
|
|
|
# Copy stack files
|
|
log_info "Copying Sablier stack files from $REPO_DIR/docker-compose/sablier/..."
|
|
cp "$REPO_DIR/docker-compose/sablier/docker-compose.yml" "$sablier_dir/" || { log_error "Failed to copy docker-compose.yml"; return 1; }
|
|
cp "$REPO_DIR/.env" "$sablier_dir/" || { log_error "Failed to copy .env"; return 1; }
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$sablier_dir/docker-compose.yml"
|
|
sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$sablier_dir/.env"
|
|
log_success "Stack files copied"
|
|
|
|
# Remove Authelia and other unnecessary variables from sablier .env
|
|
sed -i '/^AUTHELIA_/d' "$sablier_dir/.env"
|
|
sed -i '/^DEFAULT_PASSWORD=/d' "$sablier_dir/.env"
|
|
sed -i '/^CORE_SERVER_PASSWORD=/d' "$sablier_dir/.env"
|
|
|
|
# Localize the docker-compose file
|
|
localize_compose_labels "$sablier_dir/docker-compose.yml"
|
|
|
|
# Deploy
|
|
log_info "Starting Sablier container..."
|
|
cd "$sablier_dir"
|
|
if ! docker compose up -d; then
|
|
log_error "Failed to start Sablier stack"
|
|
return 1
|
|
fi
|
|
|
|
log_success "Sablier stack deployed at $sablier_dir"
|
|
}
|
|
|
|
# Remove Traefik configuration from additional server services
|
|
# Additional servers don't run local Traefik - routing is handled by core server
|
|
configure_remote_server_routing() {
|
|
debug_log "Removing Traefik labels from additional server services"
|
|
|
|
log_info "Configuring services for additional server (removing Traefik labels)..."
|
|
|
|
# Remove Traefik labels and traefik-network from dockge
|
|
if [ -f "/opt/dockge/docker-compose.yml" ]; then
|
|
# Remove all traefik.* labels (lines containing "- 'traefik.")
|
|
sed -i "/- 'traefik\./d" /opt/dockge/docker-compose.yml 2>/dev/null
|
|
# Remove traefik-network from service networks section (line with "- traefik-network")
|
|
sed -i "/^ - traefik-network$/d" /opt/dockge/docker-compose.yml 2>/dev/null
|
|
# Remove external network definition (traefik-network: and next line)
|
|
sed -i '/^ traefik-network:$/,/^ external: true$/d' /opt/dockge/docker-compose.yml 2>/dev/null
|
|
log_info "✓ Dockge: Traefik labels removed (accessible via port 5001)"
|
|
fi
|
|
|
|
# Remove Traefik labels and traefik-network from infrastructure services
|
|
if [ -f "/opt/stacks/infrastructure/docker-compose.yml" ]; then
|
|
# Remove all traefik.* and sablier.* labels
|
|
sed -i "/- 'traefik\./d" /opt/stacks/infrastructure/docker-compose.yml 2>/dev/null
|
|
sed -i "/- 'sablier\./d" /opt/stacks/infrastructure/docker-compose.yml 2>/dev/null
|
|
# Remove traefik-network from service networks sections
|
|
sed -i "/^ - traefik-network$/d" /opt/stacks/infrastructure/docker-compose.yml 2>/dev/null
|
|
# Remove external network definition
|
|
sed -i '/^ traefik-network:$/,/^ external: true$/d' /opt/stacks/infrastructure/docker-compose.yml 2>/dev/null
|
|
log_info "✓ Infrastructure: Traefik labels removed (accessible via direct ports)"
|
|
fi
|
|
|
|
log_success "Services configured for additional server - routing via core Traefik"
|
|
}
|
|
|
|
# Copy all stacks for remote server (except core)
|
|
copy_all_stacks_for_remote() {
|
|
debug_log "Copying all stacks for remote server"
|
|
|
|
# Create base stacks directory
|
|
sudo mkdir -p /opt/stacks
|
|
sudo mkdir -p /opt/dockge
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/stacks
|
|
sudo chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt/dockge
|
|
|
|
# List of stacks to copy (all except core, dockge, and traefik)
|
|
local stacks=(
|
|
"alternatives"
|
|
"dashboards"
|
|
"homeassistant"
|
|
"infrastructure"
|
|
"media"
|
|
"media-management"
|
|
"monitoring"
|
|
"productivity"
|
|
"sablier"
|
|
"transcoders"
|
|
"utilities"
|
|
"vpn"
|
|
"wikis"
|
|
)
|
|
|
|
local copied_count=0
|
|
for stack in "${stacks[@]}"; do
|
|
local src_dir="$REPO_DIR/docker-compose/$stack"
|
|
local dest_dir="/opt/stacks/$stack"
|
|
|
|
# Skip if source doesn't exist
|
|
if [ ! -d "$src_dir" ]; then
|
|
debug_log "Skipping $stack - source not found"
|
|
continue
|
|
fi
|
|
|
|
# Create destination directory
|
|
mkdir -p "$dest_dir"
|
|
|
|
# Copy docker-compose.yml and any config directories
|
|
if [ -f "$src_dir/docker-compose.yml" ]; then
|
|
cp "$src_dir/docker-compose.yml" "$dest_dir/"
|
|
cp "$REPO_DIR/.env" "$dest_dir/"
|
|
|
|
# Copy any subdirectories (config, etc.)
|
|
for item in "$src_dir"/*; do
|
|
if [ -d "$item" ]; then
|
|
cp -r "$item" "$dest_dir/"
|
|
fi
|
|
done
|
|
|
|
# Clean up sensitive data from .env
|
|
sed -i '/^AUTHELIA_/d' "$dest_dir/.env"
|
|
sed -i '/^DEFAULT_PASSWORD=/d' "$dest_dir/.env"
|
|
sed -i '/^CORE_SERVER_PASSWORD=/d' "$dest_dir/.env"
|
|
|
|
# Localize compose file
|
|
localize_compose_labels "$dest_dir/docker-compose.yml" || true
|
|
|
|
copied_count=$((copied_count + 1))
|
|
debug_log "Copied $stack to $dest_dir"
|
|
fi
|
|
done
|
|
|
|
log_success "Copied $copied_count stacks to /opt/stacks/"
|
|
}
|
|
|
|
# Deploy Traefik stack (standalone for remote servers)
|
|
deploy_traefik_stack() {
|
|
debug_log "Deploying Traefik stack"
|
|
|
|
local traefik_dir="/opt/stacks/traefik"
|
|
|
|
# Create required directories
|
|
mkdir -p "$traefik_dir/config"
|
|
mkdir -p "$traefik_dir/dynamic"
|
|
|
|
# Create placeholder routes.yml file in dynamic directory
|
|
if [ ! -f "$traefik_dir/dynamic/routes.yml" ]; then
|
|
log_info "Creating Traefik dashboard route for remote server..."
|
|
cat > "$traefik_dir/dynamic/routes.yml" <<EOF
|
|
# Traefik Dynamic Routes for Remote Server
|
|
# Auto-generated by EZ-Homelab
|
|
#
|
|
# This file is watched by Traefik and reloaded automatically
|
|
# Add custom routes here if needed
|
|
|
|
http:
|
|
routers:
|
|
traefik-dashboard:
|
|
rule: "Host(\`traefik.${SERVER_HOSTNAME}.kelinreij.duckdns.org\`)"
|
|
entryPoints:
|
|
- web
|
|
service: api@internal
|
|
EOF
|
|
log_success "Created routes.yml with dashboard route"
|
|
fi
|
|
|
|
# Verify docker-compose.yml exists
|
|
if [ ! -f "$traefik_dir/docker-compose.yml" ]; then
|
|
log_error "Traefik docker-compose.yml not found at $traefik_dir"
|
|
log_error "This should have been copied by copy_all_stacks_for_remote()"
|
|
return 1
|
|
fi
|
|
|
|
# Deploy
|
|
log_info "Starting Traefik container..."
|
|
cd "$traefik_dir"
|
|
if ! docker compose up -d; then
|
|
log_error "Failed to start Traefik stack"
|
|
log_error "Check logs: docker compose -f $traefik_dir/docker-compose.yml logs"
|
|
return 1
|
|
fi
|
|
|
|
# Verify container started
|
|
if docker ps | grep -q "traefik"; then
|
|
log_success "Traefik stack deployed and running at $traefik_dir"
|
|
else
|
|
log_warning "Traefik container may not be running, check: docker ps -a | grep traefik"
|
|
fi
|
|
}
|
|
|
|
# Show help function
|
|
show_help() {
|
|
echo "EZ-Homelab Setup & Deployment Script"
|
|
echo ""
|
|
echo "Usage: $0 [OPTIONS]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -h, --help Show this help message"
|
|
echo " -d, --dry-run Enable dry-run mode (show commands without executing)"
|
|
echo " -c, --config FILE Specify configuration file (default: .env)"
|
|
echo " -t, --test Run in test mode (validate configs without deploying)"
|
|
echo " -v, --validate-only Only validate configuration and exit"
|
|
echo " --verbose Enable verbose console logging"
|
|
echo ""
|
|
echo "If no options are provided, the interactive menu will be shown."
|
|
echo ""
|
|
}
|
|
|
|
# Validate configuration function
|
|
validate_configuration() {
|
|
log_info "Validating configuration..."
|
|
|
|
# Check if .env file exists
|
|
if [ ! -f ".env" ]; then
|
|
log_error "Configuration file .env not found."
|
|
return 1
|
|
fi
|
|
|
|
# Load and check required environment variables
|
|
if ! load_env_file; then
|
|
log_error "Failed to load .env file."
|
|
return 1
|
|
fi
|
|
|
|
# Check for critical variables
|
|
local required_vars=("DOMAIN" "SERVER_IP" "DUCKDNS_TOKEN" "AUTHELIA_ADMIN_PASSWORD_HASH")
|
|
local missing_vars=()
|
|
for var in "${required_vars[@]}"; do
|
|
if [ -z "${!var}" ]; then
|
|
missing_vars+=("$var")
|
|
fi
|
|
done
|
|
|
|
if [ ${#missing_vars[@]} -gt 0 ]; then
|
|
log_error "Missing required environment variables: ${missing_vars[*]}"
|
|
return 1
|
|
fi
|
|
|
|
# Check Docker Compose files syntax
|
|
log_info "Checking Docker Compose file syntax..."
|
|
if command -v docker-compose &> /dev/null; then
|
|
if ! docker-compose -f docker-compose/core/docker-compose.yml config -q; then
|
|
log_error "Invalid syntax in core docker-compose.yml"
|
|
return 1
|
|
fi
|
|
log_success "Core docker-compose.yml syntax is valid"
|
|
else
|
|
log_warning "docker-compose not available for syntax check"
|
|
fi
|
|
|
|
# Check network connectivity (basic)
|
|
log_info "Checking network connectivity..."
|
|
if ! ping -c 1 google.com &> /dev/null; then
|
|
log_warning "No internet connectivity detected"
|
|
else
|
|
log_success "Internet connectivity confirmed"
|
|
fi
|
|
|
|
log_success "Configuration validation completed successfully"
|
|
}
|
|
|
|
# Parse command line arguments function
|
|
parse_args() {
|
|
DRY_RUN=false
|
|
CONFIG_FILE=".env"
|
|
TEST_MODE=false
|
|
VALIDATE_ONLY=false
|
|
VERBOSE=false
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-h|--help)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
-d|--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
-c|--config)
|
|
CONFIG_FILE="$2"
|
|
shift 2
|
|
;;
|
|
-t|--test)
|
|
TEST_MODE=true
|
|
shift
|
|
;;
|
|
-v|--validate-only)
|
|
VALIDATE_ONLY=true
|
|
shift
|
|
;;
|
|
--verbose)
|
|
VERBOSE=true
|
|
shift
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Prepare deployment environment
|
|
prepare_deployment() {
|
|
# Handle special menu options
|
|
if [ "$FORCE_SYSTEM_SETUP" = true ]; then
|
|
log_info "Installing prerequisites..."
|
|
# Run the prerequisites script as root
|
|
if [ "$EUID" -eq 0 ]; then
|
|
./scripts/install-prerequisites.sh
|
|
else
|
|
sudo ./scripts/install-prerequisites.sh
|
|
fi
|
|
log_success "Prerequisites installed successfully."
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$INSTALL_NVIDIA" = true ]; then
|
|
log_info "Installing NVIDIA drivers..."
|
|
install_nvidia
|
|
exit 0
|
|
fi
|
|
|
|
# Check if system setup is needed
|
|
# Only run system setup if Docker is not installed OR if running as root and Docker setup hasn't been done
|
|
DOCKER_INSTALLED=false
|
|
if command -v docker &> /dev/null && docker --version &> /dev/null; then
|
|
DOCKER_INSTALLED=true
|
|
fi
|
|
|
|
# Check if current user is in docker group (or if we're root and will add them)
|
|
USER_IN_DOCKER_GROUP=false
|
|
if groups "$USER" 2>/dev/null | grep -q docker; then
|
|
USER_IN_DOCKER_GROUP=true
|
|
fi
|
|
|
|
if [ "$EUID" -eq 0 ]; then
|
|
# Running as root - check if we need to do system setup
|
|
if [ "$DOCKER_INSTALLED" = false ] || [ "$USER_IN_DOCKER_GROUP" = false ]; then
|
|
log_info "Docker not fully installed or user not in docker group. Performing system setup..."
|
|
./scripts/install-prerequisites.sh
|
|
echo ""
|
|
log_info "System setup complete. Please log out and back in, then run this script again."
|
|
exit 0
|
|
else
|
|
log_info "Docker is already installed and user is in docker group. Skipping system setup."
|
|
fi
|
|
else
|
|
# Not running as root
|
|
if [ "$DOCKER_INSTALLED" = false ]; then
|
|
log_error "Docker is not installed. Please run this script with sudo to perform system setup."
|
|
exit 1
|
|
fi
|
|
if [ "$USER_IN_DOCKER_GROUP" = false ]; then
|
|
log_error "Current user is not in the docker group. Please log out and back in, or run with sudo to fix group membership."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Ensure required directories exist
|
|
log_info "Ensuring required directories exist..."
|
|
if [ "$EUID" -eq 0 ]; then
|
|
ACTUAL_USER=${SUDO_USER:-$USER}
|
|
mkdir -p /opt/stacks /opt/dockge
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" /opt
|
|
else
|
|
mkdir -p /opt/stacks /opt/dockge
|
|
fi
|
|
log_success "Directories prepared"
|
|
}
|
|
|
|
# Run command function (handles dry-run and test modes)
|
|
run_cmd() {
|
|
if [ "$DRY_RUN" = true ] || [ "$TEST_MODE" = true ]; then
|
|
echo "[DRY-RUN/TEST] $@"
|
|
return 0
|
|
else
|
|
if "$@"; then
|
|
return 0
|
|
else
|
|
log_error "Command failed: $@"
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Main logic
|
|
main() {
|
|
debug_log "main() called with arguments: $@"
|
|
log_info "EZ-Homelab Unified Setup & Deployment Script"
|
|
clear
|
|
echo ""
|
|
|
|
# Parse command line arguments
|
|
parse_args "$@"
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
log_info "Dry-run mode enabled. Commands will be displayed but not executed."
|
|
fi
|
|
|
|
if [ "$VALIDATE_ONLY" = true ]; then
|
|
log_info "Validation mode enabled. Checking configuration..."
|
|
validate_configuration
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$TEST_MODE" = true ]; then
|
|
log_info "Test mode enabled. Will validate and simulate deployment."
|
|
fi
|
|
|
|
# Load existing configuration
|
|
ENV_EXISTS=false
|
|
if load_env_file; then
|
|
ENV_EXISTS=true
|
|
debug_log "Existing .env file loaded"
|
|
else
|
|
debug_log "No existing .env file found"
|
|
fi
|
|
|
|
# Menu selection loop
|
|
while true; do
|
|
# Show main menu
|
|
show_main_menu
|
|
read -p "Choose an option (1-4 or q): " MAIN_CHOICE
|
|
|
|
case $MAIN_CHOICE in
|
|
1)
|
|
log_info "Selected: Install Prerequisites"
|
|
FORCE_SYSTEM_SETUP=true
|
|
DEPLOY_CORE=false
|
|
DEPLOY_INFRASTRUCTURE=false
|
|
DEPLOY_DASHBOARDS=false
|
|
SETUP_STACKS=false
|
|
break
|
|
;;
|
|
2)
|
|
log_info "Selected: Deploy Core Server"
|
|
|
|
# Check Docker first
|
|
if ! check_docker_installed; then
|
|
echo ""
|
|
log_error "Docker must be installed before deploying core server"
|
|
log_info "Please run Option 1 (Install Prerequisites) first"
|
|
echo ""
|
|
read -p "Press Enter to return to main menu..."
|
|
continue
|
|
fi
|
|
|
|
DEPLOY_CORE=true
|
|
DEPLOY_INFRASTRUCTURE=true
|
|
DEPLOY_DASHBOARDS=true
|
|
SETUP_STACKS=true
|
|
DEPLOY_REMOTE_SERVER=false
|
|
|
|
# Set required variables for core deployment
|
|
set_required_vars_for_deployment "core"
|
|
|
|
break
|
|
;;
|
|
3)
|
|
log_info "Selected: Deploy Additional Server"
|
|
echo ""
|
|
|
|
# Check Docker first
|
|
if ! check_docker_installed; then
|
|
echo ""
|
|
log_error "Docker must be installed before deploying remote server"
|
|
log_info "Please run Option 1 (Install Prerequisites) first"
|
|
echo ""
|
|
read -p "Press Enter to return to main menu..."
|
|
continue
|
|
fi
|
|
|
|
echo "⚠️ IMPORTANT: Deploying an additional server requires an existing core server to be already deployed."
|
|
echo "The core server provides essential services like Traefik, Authelia, and shared TLS certificates."
|
|
echo ""
|
|
|
|
DEPLOY_CORE=false
|
|
DEPLOY_INFRASTRUCTURE=false
|
|
DEPLOY_DASHBOARDS=false
|
|
SETUP_STACKS=false
|
|
DEPLOY_REMOTE_SERVER=true
|
|
|
|
# Set required variables for remote deployment
|
|
set_required_vars_for_deployment "remote"
|
|
|
|
break
|
|
;;
|
|
4)
|
|
log_info "Selected: Install NVIDIA Drivers"
|
|
INSTALL_NVIDIA=true
|
|
DEPLOY_CORE=false
|
|
DEPLOY_INFRASTRUCTURE=false
|
|
DEPLOY_DASHBOARDS=false
|
|
SETUP_STACKS=false
|
|
break
|
|
;;
|
|
[Qq]|[Qq]uit)
|
|
log_info "Exiting..."
|
|
exit 0
|
|
;;
|
|
*)
|
|
log_warning "Invalid choice '$MAIN_CHOICE'. Please select 1-4 or q to quit."
|
|
echo ""
|
|
sleep 2
|
|
continue
|
|
;;
|
|
esac
|
|
done
|
|
|
|
echo ""
|
|
|
|
# Handle remote server deployment separately
|
|
if [ "$DEPLOY_REMOTE_SERVER" = true ]; then
|
|
# Prompt for configuration values
|
|
validate_and_prompt_variables
|
|
|
|
# Save configuration
|
|
save_env_file
|
|
|
|
# Reload .env file to get all variables including expanded ones
|
|
load_env_file
|
|
|
|
# Deploy remote server
|
|
deploy_remote_server
|
|
|
|
echo ""
|
|
log_success "Remote server deployment complete!"
|
|
exit 0
|
|
fi
|
|
|
|
# Prompt for configuration values
|
|
validate_and_prompt_variables
|
|
|
|
# Save configuration
|
|
save_env_file
|
|
|
|
# Validate secrets for core deployment
|
|
validate_secrets
|
|
|
|
# Perform deployment
|
|
perform_deployment
|
|
|
|
# Show completion message
|
|
echo ""
|
|
echo "╔═════════════════════════════════════════════════════════════╗"
|
|
echo "║ Deployment Complete! ║"
|
|
echo "║ SSL Certificates may take a few minutes to be issued. ║"
|
|
echo "║ ║"
|
|
echo "║ https://dockge.${DOMAIN} ║"
|
|
echo "║ http://${SERVER_IP}:5001 ║"
|
|
echo "║ ║"
|
|
echo "║ https://homepage.${DOMAIN} ║"
|
|
echo "║ http://${SERVER_IP}:3003 ║"
|
|
echo "║ ║"
|
|
echo "╚═════════════════════════════════════════════════════════════╝"
|
|
|
|
# Show consolidated warnings if any
|
|
if [ -n "$GLOBAL_MISSING_VARS" ] || [ -n "$TLS_ISSUES_SUMMARY" ]; then
|
|
echo "╔═════════════════════════════════════════════════════════════╗"
|
|
echo "║ ⚠️ WARNING ⚠️ ║"
|
|
echo "║ The following variables were not defined ║"
|
|
echo "║ If something isn't working as expected check these first ║"
|
|
echo "║ ║"
|
|
|
|
if [ -n "$GLOBAL_MISSING_VARS" ]; then
|
|
log_warning "Missing Environment Variables:"
|
|
echo "$GLOBAL_MISSING_VARS"
|
|
echo "║ ║"
|
|
fi
|
|
|
|
if [ -n "$TLS_ISSUES_SUMMARY" ]; then
|
|
log_warning "TLS Configuration Issues:"
|
|
echo "$TLS_ISSUES_SUMMARY"
|
|
echo "║ ║"
|
|
fi
|
|
fi
|
|
echo "╚═════════════════════════════════════════════════════════════╝"
|
|
|
|
echo "╔══════════════════════════════════════════╗"
|
|
echo "║ 📚 RESOURCES ║"
|
|
echo "╚══════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " 📖 Documentation: $REPO_DIR/docs/"
|
|
echo " 🔧 Quick Reference: $REPO_DIR/docs/quick-reference.md"
|
|
echo " 🐙 Repository: https://github.com/your-repo/ez-homelab"
|
|
echo " 📋 Wiki: https://github.com/your-repo/ez-homelab/wiki"
|
|
echo ""
|
|
debug_log "Script completed successfully"
|
|
echo ""
|
|
}
|
|
|
|
# Run main function
|
|
main "$@" |