diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff44a8d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +HOPS (Homelab Orchestration Provisioning Script) is a comprehensive automation tool for deploying homelab infrastructure using Docker Compose. It provides menu-driven installation, management, and monitoring of popular homelab services including media servers, download clients, monitoring tools, and more. + +## Architecture + +### Core Components + +- **Main Script (`hops.sh`)**: Primary entry point providing menu-driven interface for all operations +- **Installer (`hops_installer_enhanced.sh`)**: Handles service installation and Docker Compose deployment +- **Uninstaller (`hops_uninstaller_fixed.sh`)**: Manages complete removal of services and configurations +- **Service Definitions (`hops_service_definitions.sh`)**: Contains Docker Compose service templates and configurations + +### Key Design Patterns + +- **Modular Architecture**: Each major function is separated into dedicated scripts +- **Service-Driven**: All services are defined as Docker Compose configurations with standardized patterns +- **Error Handling**: Comprehensive error handling with logging and rollback capabilities +- **Security First**: Built-in security hardening, firewall configuration, and secure password generation + +## Development Commands + +### Running HOPS +```bash +# Main script (requires root) +sudo ./hops.sh + +# Direct installation +sudo ./hops_installer_enhanced.sh + +# Uninstallation +sudo ./hops_uninstaller_fixed.sh +``` + +### Testing and Validation +```bash +# Check script syntax +bash -n hops.sh +bash -n hops_installer_enhanced.sh +bash -n hops_service_definitions.sh +bash -n hops_uninstaller_fixed.sh + +# Test service definitions +source hops_service_definitions.sh +generate_service_definition jellyfin +``` + +### Log Management +```bash +# View installation logs +sudo tail -f /var/log/hops/hops-main-*.log + +# View Docker Compose logs +cd ~/homelab && docker compose logs -f [service-name] +``` + +## Service Architecture + +### Service Definition Pattern +All services follow a standardized Docker Compose pattern: +- LinuxServer.io containers with PUID/PGID/TZ environment variables +- Consistent volume mounting (`/opt/appdata` for configs, `/mnt/media` for data) +- Health checks for web services +- Unified network configuration (`homelab` network) +- Restart policy: `unless-stopped` + +### Supported Service Categories +1. **Media Management**: Sonarr, Radarr, Lidarr, Readarr, Bazarr, Prowlarr, Tdarr +2. **Download Clients**: qBittorrent, Transmission, NZBGet, SABnzbd +3. **Media Servers**: Jellyfin, Plex, Emby, Jellystat +4. **Request Management**: Overseerr, Jellyseerr, Ombi +5. **Reverse Proxy**: Traefik, Nginx Proxy Manager, Authelia +6. **Monitoring**: Portainer, Uptime Kuma, Watchtower + +## File Structure + +``` +~/homelab/ # Main deployment directory +├── docker-compose.yml # Generated service definitions +├── .env # Environment variables +└── logs/ # Application logs + +/opt/appdata/ # Application configurations +└── [service-name]/ # Individual service configs + +/mnt/media/ # Media storage +├── movies/ +├── tv/ +├── music/ +└── downloads/ +``` + +## Environment Configuration + +Key environment variables in `~/homelab/.env`: +- `PUID`/`PGID`: User/group IDs for file permissions +- `TZ`: Timezone configuration +- `DATA_ROOT`: Media storage location +- `CONFIG_ROOT`: Application configuration location +- Security passwords (auto-generated) + +## Security Features + +- **Firewall Integration**: Automatic UFW rule management +- **Secure Password Generation**: Cryptographically secure passwords +- **File Permission Hardening**: Restrictive permissions on sensitive files +- **Network Isolation**: Docker network segregation +- **SSL/TLS Support**: Automatic certificate management with reverse proxies + +## Error Handling + +- **Comprehensive Logging**: All operations logged to `/var/log/hops/` +- **Rollback Capability**: Automatic rollback on deployment failure +- **Dependency Validation**: Pre-deployment system requirement checks +- **Service Health Monitoring**: Built-in health checks for all services + +## Key Functions + +### In `hops.sh` +- `show_main_menu()`: Primary interface +- `manage_services()`: Service start/stop/restart +- `show_service_status()`: Real-time monitoring +- `show_access_info()`: Service URL and credential display + +### In `hops_service_definitions.sh` +- `generate_service_definition()`: Creates Docker Compose service blocks +- `get_linuxserver_env()`: Standard environment variables +- `get_web_healthcheck()`: Health check configurations + +### In `hops_installer_enhanced.sh` +- Service selection and dependency resolution +- Docker Compose file generation +- Security hardening implementation +- Post-deployment verification + +## Development Guidelines + +- **Bash Best Practices**: Use `set -e` for error handling, quote variables, use readonly for constants +- **Logging**: Use the logging functions (`log`, `error_exit`, `warning`, `success`, `info`) +- **Color Output**: Use predefined color constants for consistent formatting +- **Service Patterns**: Follow the established Docker Compose patterns when adding new services +- **Security**: Never commit secrets, use secure password generation, implement proper file permissions + +## Common Operations + +### Adding New Services +1. Add service definition function in `hops_service_definitions.sh` +2. Add service to installer menu in `hops_installer_enhanced.sh` +3. Configure any required dependencies or special handling +4. Test deployment and health checks + +### Debugging Issues +1. Check logs in `/var/log/hops/` +2. Verify Docker Compose syntax with `docker compose config` +3. Check service health with `docker compose ps` +4. Review firewall rules with `sudo ufw status` \ No newline at end of file diff --git a/README.md b/README.md index 9bc1e46..566eba2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,25 @@ **HOPS** is a comprehensive, automated deployment solution for popular homelab applications. It simplifies the process of setting up and managing Docker-based services including media servers, download clients, monitoring tools, and more. +## 🆕 What's New in v3.1.0 + +### Major Security Enhancements +- **🔐 Encrypted Secret Management**: All passwords and sensitive data now encrypted with AES-256 +- **🛡️ Input Validation**: Comprehensive validation preventing injection attacks +- **⚡ Privilege Separation**: Root operations separated from user operations +- **📌 Pinned Versions**: All container images use specific versions, not `latest` + +### New Architecture +- **📚 Modular Libraries**: Shared code organized in `lib/` directory +- **🔧 Enhanced Error Handling**: Better error messages and recovery mechanisms +- **🎯 Improved Service Definitions**: Standardized service generation with validation +- **📖 Documentation**: Complete `CLAUDE.md` for development guidance + +### Installation Methods +- **🚀 New Secure Installer**: `sudo ./hops_install.sh` - Recommended method +- **⚙️ Manual Installation**: Separate privileged and user operations +- **🔄 Legacy Support**: Original `hops.sh` still fully supported + ## 🎯 What is HOPS? HOPS (Homelab Orchestration Provisioning Script) automates the deployment of a complete homelab infrastructure using Docker Compose. It provides an intuitive menu-driven interface for selecting, configuring, and managing services with enterprise-grade features like: @@ -24,24 +43,30 @@ HOPS (Homelab Orchestration Provisioning Script) automates the deployment of a c - Automatic Docker installation and configuration - Interactive service selection - Intelligent dependency resolution +- **NEW**: Privilege separation for enhanced security ### 🔒 **Security First** - Automatic firewall configuration -- Secure password generation +- Secure password generation with encryption - File permission hardening - Network isolation +- **NEW**: AES-256 encrypted secret storage +- **NEW**: Comprehensive input validation +- **NEW**: Pinned container versions ### 📊 **Management & Monitoring** - Real-time service status monitoring - Centralized log viewing - Easy service management (start/stop/restart) - Health checks and service verification +- **NEW**: Modular architecture with shared libraries ### 🔄 **Reliability** - Error handling with automatic rollback - Service dependency management - Port conflict detection - System requirements validation +- **NEW**: Enhanced error handling with detailed context ## 📱 Supported Services @@ -100,11 +125,20 @@ HOPS (Homelab Orchestration Provisioning Script) automates the deployment of a c ```bash git clone https://github.com/skiercm/hops.git cd hops -chmod +x hops.sh +chmod +x *.sh ``` -### 2. Run Installation +### 2. Run Installation (New Improved Method) ```bash +# Option 1: Use the new secure installation wrapper +sudo ./hops_install.sh + +# Option 2: Manual two-phase installation +sudo ./hops_privileged_setup.sh # Run as root +./hops_user_operations.sh generate # Run as user +./hops_user_operations.sh deploy # Run as user + +# Option 3: Legacy installation (still supported) sudo ./hops.sh ``` @@ -165,10 +199,17 @@ sudo ./hops.sh ## 🔧 Advanced Configuration ### Environment Variables -All configuration is stored in `~/homelab/.env`: +Configuration is now stored encrypted for enhanced security: ```bash -# Core Configuration +# NEW: Encrypted secret management +./lib/secrets.sh init # Initialize secret management +./lib/secrets.sh create # Create encrypted environment +./lib/secrets.sh update DOMAIN example.com # Update values +./lib/secrets.sh get PUID # Get values +./lib/secrets.sh list # List all keys + +# Legacy: Plaintext configuration in ~/homelab/.env PUID=1000 # User ID PGID=1000 # Group ID TZ=America/New_York # Timezone @@ -177,7 +218,7 @@ TZ=America/New_York # Timezone DATA_ROOT=/mnt/media # Media storage CONFIG_ROOT=/opt/appdata # App configurations -# Security +# Security (now auto-generated and encrypted) DEFAULT_ADMIN_PASSWORD=... # Generated secure password DEFAULT_DB_PASSWORD=... # Database password @@ -188,23 +229,39 @@ ACME_EMAIL=admin@yourdomain.com ### Service Management Commands ```bash -# Navigate to homelab directory +# NEW: User operations script (runs without sudo) +./hops_user_operations.sh status # View service status +./hops_user_operations.sh logs # View service logs +./hops_user_operations.sh deploy # Deploy services +./hops_user_operations.sh stop # Stop all services + +# Legacy: Direct Docker Compose commands cd ~/homelab +docker compose ps # View running services +docker compose logs -f [service-name] # View logs +docker compose restart [service-name] # Restart specific service +docker compose pull && docker compose up -d # Update all services +docker compose down # Stop all services +``` -# View running services -docker compose ps +### New Architecture +HOPS v3.1.0 introduces a modular architecture with shared libraries: -# View logs -docker compose logs -f [service-name] - -# Restart specific service -docker compose restart [service-name] - -# Update all services -docker compose pull && docker compose up -d - -# Stop all services -docker compose down +``` +hops/ +├── lib/ # NEW: Shared libraries +│ ├── common.sh # Logging, UI, utilities +│ ├── system.sh # System validation +│ ├── docker.sh # Docker operations +│ ├── security.sh # Security utilities +│ ├── validation.sh # Input validation +│ ├── secrets.sh # Secret management +│ └── privileges.sh # Privilege management +├── hops_install.sh # NEW: Installation wrapper +├── hops_privileged_setup.sh # NEW: Root-only operations +├── hops_user_operations.sh # NEW: User operations +├── hops_service_definitions_improved.sh # NEW: Enhanced service definitions +└── hops.sh # Legacy main script (still supported) ``` ## 🔒 Security Features @@ -215,12 +272,17 @@ docker compose down - **File Permissions**: Restrictive permissions on sensitive files - **Network Isolation**: Docker network segregation - **SSL/TLS**: Automatic certificate management with Traefik +- **NEW**: AES-256 encrypted secret storage with master key management +- **NEW**: Comprehensive input validation preventing injection attacks +- **NEW**: Privilege separation (root vs user operations) +- **NEW**: Pinned container versions preventing supply chain attacks ### Post-Installation Security -1. **Change Default Passwords**: Update passwords in `.env` file +1. **Manage Encrypted Secrets**: Use `./lib/secrets.sh` for secure password management 2. **Configure Reverse Proxy**: Set up Traefik or Nginx Proxy Manager 3. **Enable Authentication**: Configure Authelia for additional security 4. **Regular Updates**: Use Watchtower for automatic updates +5. **Security Auditing**: Use `./lib/security.sh` for security checks ## 🆘 Troubleshooting @@ -310,8 +372,20 @@ We welcome contributions! Please: ```bash git clone https://github.com/skiercm/hops.git cd hops -# Make changes to scripts -# Test with: sudo ./hops.sh + +# Test syntax validation +bash -n lib/*.sh +bash -n *.sh + +# Test service definitions +./hops_service_definitions_improved.sh list +./hops_service_definitions_improved.sh generate jellyfin + +# Test new installation method +sudo ./hops_install.sh + +# Test legacy method +sudo ./hops.sh ``` ## 📄 License diff --git a/hops_install.sh b/hops_install.sh new file mode 100755 index 0000000..95df592 --- /dev/null +++ b/hops_install.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# HOPS Installation Wrapper +# Orchestrates privileged and non-privileged installation steps +# Version: 3.1.0 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +# Initialize logging +setup_logging "installation-wrapper" + +# Show header +show_hops_header "3.1.0" "Installation Wrapper" + +# Check if we're running as root +if [[ $EUID -eq 0 ]]; then + if [[ -z "$SUDO_USER" ]]; then + error_exit "Please run with sudo, not as root directly" + fi +else + error_exit "This script must be run with sudo" +fi + +# Phase 1: Privileged setup +info "📋 Phase 1: Privileged setup (requires root)" +if "$SCRIPT_DIR/hops_privileged_setup.sh"; then + success "Privileged setup completed" +else + error_exit "Privileged setup failed" +fi + +# Phase 2: User setup +info "📋 Phase 2: User setup (running as $SUDO_USER)" + +# Drop privileges and run user setup +sudo -u "$SUDO_USER" bash << 'USERSCRIPT' +cd "$HOME" +echo "Running as user: $(whoami)" + +# Interactive service selection +echo "Select services to install:" +echo "1) Media Server Stack (Jellyfin, Sonarr, Radarr, Prowlarr)" +echo "2) Download Client Stack (qBittorrent, Transmission)" +echo "3) Monitoring Stack (Portainer, Uptime Kuma)" +echo "4) Custom selection" + +read -p "Enter your choice (1-4): " choice + +case "$choice" in + 1) + services=("jellyfin" "sonarr" "radarr" "prowlarr") + ;; + 2) + services=("qbittorrent" "transmission") + ;; + 3) + services=("portainer" "uptime-kuma") + ;; + 4) + echo "Available services:" + "$SCRIPT_DIR/hops_service_definitions_improved.sh" list + read -p "Enter service names (space-separated): " -a services + ;; + *) + echo "Invalid choice" + exit 1 + ;; +esac + +# Generate and deploy +if "$SCRIPT_DIR/hops_user_operations.sh" generate "${services[@]}"; then + echo "Configuration generated successfully" + + if "$SCRIPT_DIR/hops_user_operations.sh" deploy; then + echo "Services deployed successfully" + else + echo "Deployment failed" + exit 1 + fi +else + echo "Configuration generation failed" + exit 1 +fi +USERSCRIPT + +success "Installation completed successfully" +success "Services are now running. Check status with: ./hops_user_operations.sh status" \ No newline at end of file diff --git a/hops_privileged_setup.sh b/hops_privileged_setup.sh new file mode 100755 index 0000000..d9afa7c --- /dev/null +++ b/hops_privileged_setup.sh @@ -0,0 +1,237 @@ +#!/bin/bash + +# HOPS Privileged Setup Script +# This script handles operations that require root privileges +# Version: 3.1.0 + +set -e + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/system.sh" +source "$SCRIPT_DIR/lib/security.sh" + +# Initialize logging +setup_logging "privileged-setup" + +# Check root privileges +check_root + +# Install Docker if not present +install_docker() { + info "🐳 Installing Docker..." + + if command_exists docker; then + success "Docker already installed" + return 0 + fi + + # Update package index + apt-get update + + # Install prerequisites + apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release + + # Add Docker GPG key + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + + # Add Docker repository + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Update package index with Docker packages + apt-get update + + # Install Docker + apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + + # Start and enable Docker service + systemctl start docker + systemctl enable docker + + success "Docker installed successfully" +} + +# Configure firewall +configure_firewall() { + info "🔥 Configuring firewall..." + + # Install UFW if not present + if ! command_exists ufw; then + apt-get update + apt-get install -y ufw + fi + + # Reset firewall to defaults + ufw --force reset + + # Set default policies + ufw default deny incoming + ufw default allow outgoing + + # Allow SSH (prevent lockout) + ufw allow ssh + + # Allow HTTP and HTTPS + ufw allow 80/tcp + ufw allow 443/tcp + + # Enable firewall + ufw --force enable + + success "Firewall configured successfully" +} + +# Create system directories +create_system_directories() { + info "📁 Creating system directories..." + + local directories=( + "/opt/appdata" + "/mnt/media" + "/mnt/media/movies" + "/mnt/media/tv" + "/mnt/media/music" + "/mnt/media/downloads" + "/var/log/hops" + ) + + for dir in "${directories[@]}"; do + if mkdir -p "$dir"; then + success "Created directory: $dir" + else + error_exit "Failed to create directory: $dir" + fi + done + + # Set ownership to the user who ran sudo + if [[ -n "$SUDO_USER" ]]; then + local user_info + user_info=$(get_user_info) + + local uid=$(echo "$user_info" | grep "uid=" | cut -d= -f2) + local gid=$(echo "$user_info" | grep "gid=" | cut -d= -f2) + + chown -R "$uid:$gid" /opt/appdata /mnt/media + success "Set ownership of directories to $SUDO_USER" + fi +} + +# Add user to docker group +add_user_to_docker_group() { + if [[ -z "$SUDO_USER" ]]; then + warning "No SUDO_USER set, skipping docker group addition" + return 0 + fi + + info "👥 Adding user to docker group..." + + if usermod -aG docker "$SUDO_USER"; then + success "User $SUDO_USER added to docker group" + warning "User must log out and back in for group changes to take effect" + else + error_exit "Failed to add user to docker group" + fi +} + +# Install required packages +install_packages() { + info "📦 Installing required packages..." + + apt-get update + + local packages=( + "curl" + "wget" + "git" + "jq" + "htop" + "tree" + "unzip" + "gnupg" + "software-properties-common" + "apt-transport-https" + "ca-certificates" + "lsb-release" + ) + + for package in "${packages[@]}"; do + if apt-get install -y "$package"; then + success "Installed package: $package" + else + warning "Failed to install package: $package" + fi + done +} + +# Setup secrets directory +setup_secrets_directory() { + info "🔐 Setting up secrets directory..." + + local secrets_dir="/etc/hops/secrets" + + if mkdir -p "$secrets_dir"; then + chmod 700 "$secrets_dir" + success "Secrets directory created: $secrets_dir" + else + error_exit "Failed to create secrets directory" + fi +} + +# Configure system settings +configure_system() { + info "⚙️ Configuring system settings..." + + # Set timezone if not already set + if [[ -n "$TZ" ]]; then + timedatectl set-timezone "$TZ" 2>/dev/null || true + fi + + # Enable IP forwarding for Docker + echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf + sysctl -p /etc/sysctl.conf + + success "System configuration completed" +} + +# Main privileged setup +main() { + info "🚀 Starting privileged setup..." + + # System checks + detect_os + check_system_requirements + + # Install packages + install_packages + + # Install Docker + install_docker + + # Configure firewall + configure_firewall + + # Create directories + create_system_directories + + # Add user to docker group + add_user_to_docker_group + + # Setup secrets + setup_secrets_directory + + # Configure system + configure_system + + success "Privileged setup completed successfully" + success "Please log out and back in for group changes to take effect" +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/hops_service_definitions_improved.sh b/hops_service_definitions_improved.sh new file mode 100755 index 0000000..22328d0 --- /dev/null +++ b/hops_service_definitions_improved.sh @@ -0,0 +1,572 @@ +#!/bin/bash + +# HOPS Service Definitions - Improved Version +# Contains all Docker Compose service configurations with error handling and pinned versions +# Version: 3.1.0 + +# Exit on any error +set -e + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then + source "$SCRIPT_DIR/lib/common.sh" +else + echo "ERROR: Common library not found. Please ensure lib/common.sh exists." >&2 + exit 1 +fi + +if [[ -f "$SCRIPT_DIR/lib/security.sh" ]]; then + source "$SCRIPT_DIR/lib/security.sh" +else + echo "ERROR: Security library not found. Please ensure lib/security.sh exists." >&2 + exit 1 +fi + +# Service definitions with pinned versions (from docker.sh) +declare -A SERVICE_IMAGES=( + # Media Management (*arr Stack) + ["sonarr"]="lscr.io/linuxserver/sonarr:4.0.10" + ["radarr"]="lscr.io/linuxserver/radarr:5.8.3" + ["lidarr"]="lscr.io/linuxserver/lidarr:2.5.3" + ["readarr"]="lscr.io/linuxserver/readarr:0.3.32-develop" + ["bazarr"]="lscr.io/linuxserver/bazarr:1.4.3" + ["prowlarr"]="lscr.io/linuxserver/prowlarr:1.24.3" + ["tdarr"]="ghcr.io/haveagitgat/tdarr:2.26.01" + + # Download Clients + ["qbittorrent"]="lscr.io/linuxserver/qbittorrent:4.6.7" + ["transmission"]="lscr.io/linuxserver/transmission:4.0.6" + ["nzbget"]="lscr.io/linuxserver/nzbget:24.3" + ["sabnzbd"]="lscr.io/linuxserver/sabnzbd:4.3.3" + + # Media Servers + ["jellyfin"]="lscr.io/linuxserver/jellyfin:10.9.11" + ["plex"]="lscr.io/linuxserver/plex:1.40.5" + ["emby"]="lscr.io/linuxserver/emby:4.8.8" + ["jellystat"]="cyfershepard/jellystat:1.1.0" + + # Request Management + ["overseerr"]="lscr.io/linuxserver/overseerr:1.33.2" + ["jellyseerr"]="fallenbagel/jellyseerr:1.9.2" + ["ombi"]="lscr.io/linuxserver/ombi:4.43.5" + + # Reverse Proxy & Security + ["traefik"]="traefik:v3.1.6" + ["nginx-proxy-manager"]="jc21/nginx-proxy-manager:2.11.3" + ["authelia"]="authelia/authelia:4.38.16" + + # Monitoring & Management + ["portainer"]="portainer/portainer-ce:2.21.4" + ["uptime-kuma"]="louislam/uptime-kuma:1.23.15" + ["watchtower"]="containrrr/watchtower:1.7.1" +) + +# Service port mapping +declare -A SERVICE_PORTS=( + ["sonarr"]="8989" + ["radarr"]="7878" + ["lidarr"]="8686" + ["readarr"]="8787" + ["bazarr"]="6767" + ["prowlarr"]="9696" + ["tdarr"]="8265" + ["qbittorrent"]="8082" + ["transmission"]="9091" + ["nzbget"]="6789" + ["sabnzbd"]="8080" + ["jellyfin"]="8096" + ["plex"]="32400" + ["emby"]="8096" + ["jellystat"]="3000" + ["overseerr"]="5055" + ["jellyseerr"]="5056" + ["ombi"]="3579" + ["traefik"]="8080" + ["nginx-proxy-manager"]="81" + ["authelia"]="9091" + ["portainer"]="9000" + ["uptime-kuma"]="3001" + ["watchtower"]="8080" +) + +# Initialize logging +setup_logging "service-definitions" + +# Validate service name +validate_service_name_internal() { + local service_name="$1" + + if [[ -z "$service_name" ]]; then + error_exit "Service name is required" + fi + + if ! validate_service_name "$service_name"; then + error_exit "Invalid service name: $service_name" + fi + + if [[ -z "${SERVICE_IMAGES[$service_name]}" ]]; then + error_exit "Unknown service: $service_name" + fi +} + +# Get service image with validation +get_service_image() { + local service_name="$1" + validate_service_name_internal "$service_name" + echo "${SERVICE_IMAGES[$service_name]}" +} + +# Get service port with validation +get_service_port() { + local service_name="$1" + validate_service_name_internal "$service_name" + echo "${SERVICE_PORTS[$service_name]}" +} + +# Common environment variables for LinuxServer containers +get_linuxserver_env() { + cat < [service_name...]" + fi + generate_multiple_services "$@" + ;; + + "list") + list_available_services + ;; + + "validate") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 validate " + fi + validate_service_config "$1" + ;; + + "help"|"--help"|"-h") + cat < [options] + +Actions: + generate ... Generate service definitions + list List all available services + validate Validate service configuration + help Show this help message + +Examples: + $0 generate sonarr radarr jellyfin + $0 list + $0 validate traefik + +EOF + ;; + + *) + error_exit "Unknown action: $action. Use 'help' for usage information." + ;; + esac +} + +# If script is run directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/hops_user_operations.sh b/hops_user_operations.sh new file mode 100755 index 0000000..3b6e278 --- /dev/null +++ b/hops_user_operations.sh @@ -0,0 +1,175 @@ +#!/bin/bash + +# HOPS User Script +# This script handles operations that can run as regular user +# Version: 3.1.0 + +set -e + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/docker.sh" +source "$SCRIPT_DIR/lib/validation.sh" + +# Initialize logging +setup_logging "user-operations" + +# Check if user is in docker group +check_docker_access() { + if ! groups "$USER" | grep -q docker; then + error_exit "User not in docker group. Please run the privileged setup first and log out/in." + fi + + if ! docker info >/dev/null 2>&1; then + error_exit "Cannot access Docker daemon. Please ensure Docker is running." + fi +} + +# Generate Docker Compose configuration +generate_docker_compose() { + local services=("$@") + local compose_file="$HOME/homelab/docker-compose.yml" + + info "📝 Generating Docker Compose configuration..." + + # Create homelab directory + mkdir -p "$HOME/homelab" + + # Generate compose file header + cat > "$compose_file" << EOF +version: '3.8' + +services: +EOF + + # Generate service definitions + for service in "${services[@]}"; do + if "$SCRIPT_DIR/hops_service_definitions_improved.sh" generate "$service" >> "$compose_file"; then + success "Added service: $service" + else + error_exit "Failed to generate service definition for: $service" + fi + done + + # Add networks section + cat >> "$compose_file" << EOF + +networks: + homelab: + driver: bridge + traefik: + driver: bridge + database: + driver: bridge +EOF + + success "Docker Compose configuration generated: $compose_file" +} + +# Deploy services +deploy_services() { + local compose_file="$HOME/homelab/docker-compose.yml" + + if [[ ! -f "$compose_file" ]]; then + error_exit "Docker Compose file not found: $compose_file" + fi + + info "🚀 Deploying services..." + + cd "$HOME/homelab" + + # Pull images + if docker compose pull; then + success "Docker images pulled successfully" + else + error_exit "Failed to pull Docker images" + fi + + # Start services + if docker compose up -d; then + success "Services deployed successfully" + else + error_exit "Failed to deploy services" + fi +} + +# Stop services +stop_services() { + local compose_file="$HOME/homelab/docker-compose.yml" + + if [[ ! -f "$compose_file" ]]; then + error_exit "Docker Compose file not found: $compose_file" + fi + + info "🛑 Stopping services..." + + cd "$HOME/homelab" + + if docker compose down; then + success "Services stopped successfully" + else + error_exit "Failed to stop services" + fi +} + +# Show service status +show_service_status() { + local compose_file="$HOME/homelab/docker-compose.yml" + + if [[ ! -f "$compose_file" ]]; then + error_exit "Docker Compose file not found: $compose_file" + fi + + info "📊 Service status:" + + cd "$HOME/homelab" + docker compose ps +} + +# Main user operations +main() { + local action="$1" + shift + + # Check Docker access + check_docker_access + + case "$action" in + "generate") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 generate [service2] ..." + fi + generate_docker_compose "$@" + ;; + + "deploy") + deploy_services + ;; + + "stop") + stop_services + ;; + + "status") + show_service_status + ;; + + "logs") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 logs " + fi + cd "$HOME/homelab" + docker compose logs -f "$1" + ;; + + *) + error_exit "Unknown action: $action" + ;; + esac +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..ad31507 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,251 @@ +#!/bin/bash + +# HOPS - Common Utility Functions +# Shared functions for logging, error handling, and UI +# Version: 3.1.0 + +# Color codes for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly PURPLE='\033[0;35m' +readonly CYAN='\033[0;36m' +readonly WHITE='\033[1;37m' +readonly NC='\033[0m' # No Color + +# Global variables (set by setup_logging) +LOG_DIR="" +LOG_FILE="" + +# Initialize logging system +setup_logging() { + local log_prefix="$1" + + if [[ -z "$log_prefix" ]]; then + echo "ERROR: setup_logging requires a log prefix" >&2 + return 1 + fi + + LOG_DIR="/var/log/hops" + LOG_FILE="$LOG_DIR/${log_prefix}-$(date +%Y%m%d-%H%M%S).log" + + if [[ $EUID -eq 0 ]]; then + mkdir -p "$LOG_DIR" + touch "$LOG_FILE" + else + echo "WARNING: Not running as root, logging to console only" >&2 + fi +} + +# Unified logging function +log() { + local message="$1" + local timestamp="$(date '+%Y-%m-%d %T')" + + # Write to log file if available + if [[ -n "$LOG_FILE" && -w "$LOG_FILE" ]]; then + echo "$timestamp - $message" >> "$LOG_FILE" + fi + + # Always output to console + echo -e "$message" +} + +# Error handling with exit +error_exit() { + log "${RED}❌ ERROR: $1${NC}" + if [[ -n "$LOG_FILE" ]]; then + log "${RED}❌ Operation failed. Check logs at: $LOG_FILE${NC}" + fi + exit 1 +} + +# Warning function +warning() { + log "${YELLOW}⚠️ WARNING: $1${NC}" +} + +# Success function +success() { + log "${GREEN}✅ $1${NC}" +} + +# Info function +info() { + log "${BLUE}ℹ️ $1${NC}" +} + +# Debug function (only shows if DEBUG=1) +debug() { + if [[ "${DEBUG:-0}" == "1" ]]; then + log "${PURPLE}🐛 DEBUG: $1${NC}" + fi +} + +# Show HOPS header +show_hops_header() { + local version="$1" + local subtitle="$2" + + if [[ -z "$version" ]]; then + version="3.1.0" + fi + + clear + cat << "EOF" + + _ _ ____ ____ ____ + | | | || _ \| _ \/ ___| + | |__| || |_) | |_) \___ \ + | __ || __/| __/ ___) | + |_| |_||_| |_| |____/ + +EOF + echo -e "${CYAN}🚀 Homelab Orchestration Provisioning Script v${version}${NC}" + + if [[ -n "$subtitle" ]]; then + echo -e "${WHITE}${subtitle}${NC}" + fi + + echo -e "${WHITE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" +} + +# Progress indicator +show_progress() { + local current="$1" + local total="$2" + local message="$3" + local width=50 + + local percentage=$((current * 100 / total)) + local filled=$((current * width / total)) + local empty=$((width - filled)) + + printf "\r${BLUE}[${NC}" + printf "%${filled}s" | tr ' ' '=' + printf "%${empty}s" | tr ' ' '-' + printf "${BLUE}] %3d%% %s${NC}" "$percentage" "$message" + + if [[ $current -eq $total ]]; then + echo + fi +} + +# Confirmation prompt +confirm() { + local message="$1" + local default="${2:-n}" + local prompt + + case "$default" in + [Yy]|[Yy][Ee][Ss]) prompt="[Y/n]" ;; + [Nn]|[Nn][Oo]) prompt="[y/N]" ;; + *) prompt="[y/n]" ;; + esac + + while true; do + read -r -p "${message} ${prompt}: " response + + # Use default if empty response + if [[ -z "$response" ]]; then + response="$default" + fi + + case "$response" in + [Yy]|[Yy][Ee][Ss]) return 0 ;; + [Nn]|[Nn][Oo]) return 1 ;; + *) echo "Please answer yes or no." ;; + esac + done +} + +# Spinner for long operations +spinner() { + local pid=$1 + local message="$2" + local spin='-\|/' + local i=0 + + while kill -0 "$pid" 2>/dev/null; do + printf "\r${BLUE}%s %s${NC}" "${spin:i++%${#spin}:1}" "$message" + sleep 0.1 + done + + printf "\r" +} + +# Check if running as root +check_root() { + if [[ $EUID -ne 0 ]]; then + error_exit "This script must be run as root or with sudo." + fi +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Wait for user input +pause() { + local message="${1:-Press any key to continue...}" + read -n 1 -s -r -p "$message" + echo +} + +# Format bytes to human readable +format_bytes() { + local bytes=$1 + local units=("B" "KB" "MB" "GB" "TB") + local unit=0 + + while [[ $bytes -gt 1024 && $unit -lt 4 ]]; do + bytes=$((bytes / 1024)) + ((unit++)) + done + + echo "${bytes}${units[$unit]}" +} + +# Check if string is a valid IP address +is_valid_ip() { + local ip=$1 + local regex="^([0-9]{1,3}\.){3}[0-9]{1,3}$" + + if [[ $ip =~ $regex ]]; then + local IFS='.' + local -a octets=($ip) + + for octet in "${octets[@]}"; do + if [[ $octet -gt 255 ]]; then + return 1 + fi + done + + return 0 + fi + + return 1 +} + +# Check if port is available +is_port_available() { + local port=$1 + ! ss -tuln | grep -q ":$port " +} + +# Get available port starting from given port +get_available_port() { + local start_port=$1 + local port=$start_port + + while ! is_port_available "$port"; do + ((port++)) + if [[ $port -gt 65535 ]]; then + error_exit "No available ports found starting from $start_port" + fi + done + + echo "$port" +} \ No newline at end of file diff --git a/lib/docker.sh b/lib/docker.sh new file mode 100644 index 0000000..1426aeb --- /dev/null +++ b/lib/docker.sh @@ -0,0 +1,484 @@ +#!/bin/bash + +# HOPS - Docker Service Management +# Functions for Docker service management and monitoring +# Version: 3.1.0 + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Service definitions with pinned versions +declare -A HOPS_SERVICES=( + # Media Management (*arr Stack) + ["sonarr"]="8989:lscr.io/linuxserver/sonarr:4.0.10" + ["radarr"]="7878:lscr.io/linuxserver/radarr:5.8.3" + ["lidarr"]="8686:lscr.io/linuxserver/lidarr:2.5.3" + ["readarr"]="8787:lscr.io/linuxserver/readarr:0.3.32-develop" + ["bazarr"]="6767:lscr.io/linuxserver/bazarr:1.4.3" + ["prowlarr"]="9696:lscr.io/linuxserver/prowlarr:1.24.3" + ["tdarr"]="8265:ghcr.io/haveagitgat/tdarr:2.26.01" + + # Download Clients + ["qbittorrent"]="8082:lscr.io/linuxserver/qbittorrent:4.6.7" + ["transmission"]="9091:lscr.io/linuxserver/transmission:4.0.6" + ["nzbget"]="6789:lscr.io/linuxserver/nzbget:24.3" + ["sabnzbd"]="8080:lscr.io/linuxserver/sabnzbd:4.3.3" + + # Media Servers + ["jellyfin"]="8096:lscr.io/linuxserver/jellyfin:10.9.11" + ["plex"]="32400:lscr.io/linuxserver/plex:1.40.5" + ["emby"]="8096:lscr.io/linuxserver/emby:4.8.8" + ["jellystat"]="3000:cyfershepard/jellystat:1.1.0" + + # Request Management + ["overseerr"]="5055:lscr.io/linuxserver/overseerr:1.33.2" + ["jellyseerr"]="5056:fallenbagel/jellyseerr:1.9.2" + ["ombi"]="3579:lscr.io/linuxserver/ombi:4.43.5" + + # Reverse Proxy & Security + ["traefik"]="8080:traefik:v3.1.6" + ["nginx-proxy-manager"]="81:jc21/nginx-proxy-manager:2.11.3" + ["authelia"]="9091:authelia/authelia:4.38.16" + + # Monitoring & Management + ["portainer"]="9000:portainer/portainer-ce:2.21.4" + ["uptime-kuma"]="3001:louislam/uptime-kuma:1.23.15" + ["watchtower"]="8080:containrrr/watchtower:1.7.1" +) + +# Get service port and image +get_service_info() { + local service_name="$1" + local info="${HOPS_SERVICES[$service_name]}" + + if [[ -z "$info" ]]; then + error_exit "Unknown service: $service_name" + fi + + echo "$info" +} + +# Get service port +get_service_port() { + local service_name="$1" + local info=$(get_service_info "$service_name") + echo "${info%%:*}" +} + +# Get service image +get_service_image() { + local service_name="$1" + local info=$(get_service_info "$service_name") + echo "${info#*:}" +} + +# List all available services +list_services() { + echo "Available HOPS services:" + echo + + local categories=( + "Media Management:sonarr,radarr,lidarr,readarr,bazarr,prowlarr,tdarr" + "Download Clients:qbittorrent,transmission,nzbget,sabnzbd" + "Media Servers:jellyfin,plex,emby,jellystat" + "Request Management:overseerr,jellyseerr,ombi" + "Reverse Proxy & Security:traefik,nginx-proxy-manager,authelia" + "Monitoring & Management:portainer,uptime-kuma,watchtower" + ) + + for category in "${categories[@]}"; do + local category_name="${category%%:*}" + local services="${category#*:}" + + echo -e "${CYAN}${category_name}:${NC}" + IFS=',' read -ra service_list <<< "$services" + + for service in "${service_list[@]}"; do + local port=$(get_service_port "$service") + local image=$(get_service_image "$service") + printf " %-20s Port: %-6s Image: %s\n" "$service" "$port" "$image" + done + echo + done +} + +# Check if service is running +is_service_running() { + local service_name="$1" + + if [[ -z "$service_name" ]]; then + return 1 + fi + + docker ps --format "{{.Names}}" | grep -q "^${service_name}$" +} + +# Check if service exists (running or stopped) +service_exists() { + local service_name="$1" + + if [[ -z "$service_name" ]]; then + return 1 + fi + + docker ps -a --format "{{.Names}}" | grep -q "^${service_name}$" +} + +# Get service status with health information +get_service_status() { + local service_name="$1" + + if [[ -z "$service_name" ]]; then + echo "invalid" + return 1 + fi + + if is_service_running "$service_name"; then + local port=$(get_service_port "$service_name") + + # Check if service is accessible + if curl -sSf --max-time 2 --connect-timeout 1 "http://localhost:${port}" >/dev/null 2>&1; then + echo "running_accessible" + else + # Check if it's still starting up + local container_uptime=$(docker ps --format "{{.Status}}" --filter "name=^${service_name}$" | grep -oE 'Up [0-9]+ (second|minute)s?') + if [[ -n "$container_uptime" ]]; then + echo "running_starting" + else + echo "running_error" + fi + fi + elif service_exists "$service_name"; then + echo "stopped" + else + echo "not_found" + fi +} + +# Get detailed service information +get_service_details() { + local service_name="$1" + + if ! service_exists "$service_name"; then + error_exit "Service $service_name not found" + fi + + local port=$(get_service_port "$service_name") + local image=$(get_service_image "$service_name") + local status=$(get_service_status "$service_name") + + echo "Service: $service_name" + echo "Port: $port" + echo "Image: $image" + echo "Status: $status" + + # Additional Docker info + if service_exists "$service_name"; then + echo "Container Info:" + docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" --filter "name=^${service_name}$" + fi +} + +# Get all running HOPS services +get_running_services() { + local running_services=() + + for service_name in "${!HOPS_SERVICES[@]}"; do + if is_service_running "$service_name"; then + running_services+=("$service_name") + fi + done + + echo "${running_services[@]}" +} + +# Get all stopped HOPS services +get_stopped_services() { + local stopped_services=() + + for service_name in "${!HOPS_SERVICES[@]}"; do + if service_exists "$service_name" && ! is_service_running "$service_name"; then + stopped_services+=("$service_name") + fi + done + + echo "${stopped_services[@]}" +} + +# Check if Docker daemon is running +check_docker_daemon() { + if ! docker info >/dev/null 2>&1; then + error_exit "Docker daemon is not running. Please start Docker and try again." + fi +} + +# Check if Docker Compose is available +check_docker_compose() { + if ! docker compose version >/dev/null 2>&1; then + error_exit "Docker Compose not available. Please install Docker Compose v2+" + fi +} + +# Pull service images +pull_service_images() { + local services=("$@") + + if [[ ${#services[@]} -eq 0 ]]; then + error_exit "No services specified for image pull" + fi + + info "🐳 Pulling Docker images for selected services..." + + local total=${#services[@]} + local current=0 + + for service in "${services[@]}"; do + ((current++)) + local image=$(get_service_image "$service") + + show_progress "$current" "$total" "Pulling $service ($image)" + + if ! docker pull "$image" >/dev/null 2>&1; then + error_exit "Failed to pull image: $image" + fi + done + + success "All images pulled successfully" +} + +# Create Docker networks +create_docker_networks() { + local networks=("homelab" "traefik" "database") + + info "🌐 Creating Docker networks..." + + for network in "${networks[@]}"; do + if docker network ls --format "{{.Name}}" | grep -q "^${network}$"; then + debug "Network $network already exists" + else + if docker network create "$network" >/dev/null 2>&1; then + success "Created network: $network" + else + error_exit "Failed to create network: $network" + fi + fi + done +} + +# Remove Docker networks +remove_docker_networks() { + local networks=("homelab" "traefik" "database") + + info "🗑️ Removing Docker networks..." + + for network in "${networks[@]}"; do + if docker network ls --format "{{.Name}}" | grep -q "^${network}$"; then + if docker network rm "$network" >/dev/null 2>&1; then + success "Removed network: $network" + else + warning "Failed to remove network: $network (may have active containers)" + fi + fi + done +} + +# Check for port conflicts +check_port_conflicts() { + local services=("$@") + local conflicts=() + + info "🔍 Checking for port conflicts..." + + for service in "${services[@]}"; do + local port=$(get_service_port "$service") + + if ! is_port_available "$port"; then + local process=$(ss -tuln | grep ":$port " | head -n1) + conflicts+=("$service:$port ($process)") + fi + done + + if [[ ${#conflicts[@]} -gt 0 ]]; then + error_exit "Port conflicts detected:\n$(printf ' • %s\n' "${conflicts[@]}")" + fi + + success "No port conflicts detected" +} + +# Monitor service health +monitor_service_health() { + local service_name="$1" + local timeout="${2:-60}" + local interval="${3:-5}" + + info "🏥 Monitoring health of $service_name..." + + local elapsed=0 + local port=$(get_service_port "$service_name") + + while [[ $elapsed -lt $timeout ]]; do + if is_service_running "$service_name"; then + if curl -sSf --max-time 2 --connect-timeout 1 "http://localhost:${port}" >/dev/null 2>&1; then + success "$service_name is healthy and accessible" + return 0 + fi + else + warning "$service_name is not running" + return 1 + fi + + sleep "$interval" + elapsed=$((elapsed + interval)) + + printf "\r${BLUE}⏳ Waiting for $service_name to become healthy... (${elapsed}s/${timeout}s)${NC}" + done + + echo + error_exit "$service_name failed to become healthy within ${timeout}s" +} + +# Get service logs +get_service_logs() { + local service_name="$1" + local lines="${2:-50}" + + if ! service_exists "$service_name"; then + error_exit "Service $service_name not found" + fi + + info "📋 Showing last $lines lines of logs for $service_name:" + docker logs --tail "$lines" "$service_name" 2>&1 +} + +# Restart service +restart_service() { + local service_name="$1" + + if ! service_exists "$service_name"; then + error_exit "Service $service_name not found" + fi + + info "🔄 Restarting $service_name..." + + if docker restart "$service_name" >/dev/null 2>&1; then + success "$service_name restarted successfully" + monitor_service_health "$service_name" 30 + else + error_exit "Failed to restart $service_name" + fi +} + +# Stop service +stop_service() { + local service_name="$1" + + if ! is_service_running "$service_name"; then + warning "$service_name is not running" + return 0 + fi + + info "🛑 Stopping $service_name..." + + if docker stop "$service_name" >/dev/null 2>&1; then + success "$service_name stopped successfully" + else + error_exit "Failed to stop $service_name" + fi +} + +# Start service +start_service() { + local service_name="$1" + + if is_service_running "$service_name"; then + warning "$service_name is already running" + return 0 + fi + + if ! service_exists "$service_name"; then + error_exit "Service $service_name not found" + fi + + info "▶️ Starting $service_name..." + + if docker start "$service_name" >/dev/null 2>&1; then + success "$service_name started successfully" + monitor_service_health "$service_name" 30 + else + error_exit "Failed to start $service_name" + fi +} + +# Remove service +remove_service() { + local service_name="$1" + local remove_volumes="${2:-false}" + + if ! service_exists "$service_name"; then + warning "$service_name does not exist" + return 0 + fi + + info "🗑️ Removing $service_name..." + + # Stop if running + if is_service_running "$service_name"; then + stop_service "$service_name" + fi + + # Remove container + if docker rm "$service_name" >/dev/null 2>&1; then + success "$service_name removed successfully" + else + error_exit "Failed to remove $service_name" + fi + + # Remove volumes if requested + if [[ "$remove_volumes" == "true" ]]; then + info "🗑️ Removing volumes for $service_name..." + docker volume ls -q | grep "$service_name" | xargs -r docker volume rm 2>/dev/null || true + fi +} + +# Update service image +update_service() { + local service_name="$1" + + if ! service_exists "$service_name"; then + error_exit "Service $service_name not found" + fi + + local image=$(get_service_image "$service_name") + + info "🔄 Updating $service_name to latest image..." + + # Pull latest image + if docker pull "$image" >/dev/null 2>&1; then + success "Pulled latest image: $image" + else + error_exit "Failed to pull image: $image" + fi + + # Restart service to use new image + restart_service "$service_name" +} + +# Clean up unused Docker resources +cleanup_docker() { + info "🧹 Cleaning up unused Docker resources..." + + # Remove unused containers + docker container prune -f >/dev/null 2>&1 + + # Remove unused images + docker image prune -f >/dev/null 2>&1 + + # Remove unused volumes + docker volume prune -f >/dev/null 2>&1 + + # Remove unused networks + docker network prune -f >/dev/null 2>&1 + + success "Docker cleanup completed" +} \ No newline at end of file diff --git a/lib/privileges.sh b/lib/privileges.sh new file mode 100755 index 0000000..eae2893 --- /dev/null +++ b/lib/privileges.sh @@ -0,0 +1,750 @@ +#!/bin/bash + +# HOPS - Privilege Management System +# Split operations into privileged and non-privileged components +# Version: 3.1.0 + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Operations that require root privileges +PRIVILEGED_OPERATIONS=( + "install_docker" + "configure_firewall" + "create_system_directories" + "install_packages" + "configure_systemd" + "setup_secrets_directory" + "modify_system_files" +) + +# Operations that can run as regular user +NON_PRIVILEGED_OPERATIONS=( + "generate_docker_compose" + "pull_docker_images" + "start_containers" + "stop_containers" + "view_logs" + "check_service_status" + "validate_configuration" + "backup_user_data" +) + +# Check if operation requires privileges +requires_privileges() { + local operation="$1" + + for priv_op in "${PRIVILEGED_OPERATIONS[@]}"; do + if [[ "$operation" == "$priv_op" ]]; then + return 0 + fi + done + + return 1 +} + +# Check if operation can run as regular user +can_run_as_user() { + local operation="$1" + + for user_op in "${NON_PRIVILEGED_OPERATIONS[@]}"; do + if [[ "$operation" == "$user_op" ]]; then + return 0 + fi + done + + return 1 +} + +# Get current user information +get_current_user_info() { + local -A user_info + + if [[ -n "$SUDO_USER" ]]; then + user_info["username"]="$SUDO_USER" + user_info["uid"]=$(id -u "$SUDO_USER") + user_info["gid"]=$(id -g "$SUDO_USER") + user_info["home"]=$(eval echo "~$SUDO_USER") + user_info["is_sudo"]="true" + else + user_info["username"]="$USER" + user_info["uid"]=$(id -u) + user_info["gid"]=$(id -g) + user_info["home"]="$HOME" + user_info["is_sudo"]="false" + fi + + # Return as key=value pairs + for key in "${!user_info[@]}"; do + echo "${key}=${user_info[$key]}" + done +} + +# Drop privileges to regular user +drop_privileges() { + local command="$1" + shift + local args=("$@") + + if [[ $EUID -ne 0 ]]; then + debug "Already running as non-root user" + exec "$command" "${args[@]}" + return $? + fi + + if [[ -z "$SUDO_USER" ]]; then + error_exit "Cannot drop privileges: SUDO_USER not set" + fi + + local user_info + user_info=$(get_current_user_info) + + local uid=$(echo "$user_info" | grep "uid=" | cut -d= -f2) + local gid=$(echo "$user_info" | grep "gid=" | cut -d= -f2) + local home=$(echo "$user_info" | grep "home=" | cut -d= -f2) + + debug "Dropping privileges to user: $SUDO_USER (uid=$uid, gid=$gid)" + + # Set environment variables for the user + local env_vars=( + "HOME=$home" + "USER=$SUDO_USER" + "LOGNAME=$SUDO_USER" + "PATH=/usr/local/bin:/usr/bin:/bin" + ) + + # Execute command as user + sudo -u "$SUDO_USER" env "${env_vars[@]}" "$command" "${args[@]}" +} + +# Run operation with appropriate privileges +run_with_privileges() { + local operation="$1" + local command="$2" + shift 2 + local args=("$@") + + if requires_privileges "$operation"; then + debug "Operation '$operation' requires root privileges" + + if [[ $EUID -ne 0 ]]; then + error_exit "Operation '$operation' requires root privileges. Please run with sudo." + fi + + # Run as root + exec "$command" "${args[@]}" + elif can_run_as_user "$operation"; then + debug "Operation '$operation' can run as regular user" + + if [[ $EUID -eq 0 ]]; then + # Drop privileges + drop_privileges "$command" "${args[@]}" + else + # Run as current user + exec "$command" "${args[@]}" + fi + else + error_exit "Unknown operation: $operation" + fi +} + +# Create privileged setup script +create_privileged_setup() { + local setup_script="$1" + + cat > "$setup_script" << 'EOF' +#!/bin/bash + +# HOPS Privileged Setup Script +# This script handles operations that require root privileges +# Version: 3.1.0 + +set -e + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/system.sh" +source "$SCRIPT_DIR/lib/security.sh" + +# Initialize logging +setup_logging "privileged-setup" + +# Check root privileges +check_root + +# Install Docker if not present +install_docker() { + info "🐳 Installing Docker..." + + if command_exists docker; then + success "Docker already installed" + return 0 + fi + + # Update package index + apt-get update + + # Install prerequisites + apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release + + # Add Docker GPG key + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + + # Add Docker repository + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Update package index with Docker packages + apt-get update + + # Install Docker + apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + + # Start and enable Docker service + systemctl start docker + systemctl enable docker + + success "Docker installed successfully" +} + +# Configure firewall +configure_firewall() { + info "🔥 Configuring firewall..." + + # Install UFW if not present + if ! command_exists ufw; then + apt-get update + apt-get install -y ufw + fi + + # Reset firewall to defaults + ufw --force reset + + # Set default policies + ufw default deny incoming + ufw default allow outgoing + + # Allow SSH (prevent lockout) + ufw allow ssh + + # Allow HTTP and HTTPS + ufw allow 80/tcp + ufw allow 443/tcp + + # Enable firewall + ufw --force enable + + success "Firewall configured successfully" +} + +# Create system directories +create_system_directories() { + info "📁 Creating system directories..." + + local directories=( + "/opt/appdata" + "/mnt/media" + "/mnt/media/movies" + "/mnt/media/tv" + "/mnt/media/music" + "/mnt/media/downloads" + "/var/log/hops" + ) + + for dir in "${directories[@]}"; do + if mkdir -p "$dir"; then + success "Created directory: $dir" + else + error_exit "Failed to create directory: $dir" + fi + done + + # Set ownership to the user who ran sudo + if [[ -n "$SUDO_USER" ]]; then + local user_info + user_info=$(get_user_info) + + local uid=$(echo "$user_info" | grep "uid=" | cut -d= -f2) + local gid=$(echo "$user_info" | grep "gid=" | cut -d= -f2) + + chown -R "$uid:$gid" /opt/appdata /mnt/media + success "Set ownership of directories to $SUDO_USER" + fi +} + +# Add user to docker group +add_user_to_docker_group() { + if [[ -z "$SUDO_USER" ]]; then + warning "No SUDO_USER set, skipping docker group addition" + return 0 + fi + + info "👥 Adding user to docker group..." + + if usermod -aG docker "$SUDO_USER"; then + success "User $SUDO_USER added to docker group" + warning "User must log out and back in for group changes to take effect" + else + error_exit "Failed to add user to docker group" + fi +} + +# Install required packages +install_packages() { + info "📦 Installing required packages..." + + apt-get update + + local packages=( + "curl" + "wget" + "git" + "jq" + "htop" + "tree" + "unzip" + "gnupg" + "software-properties-common" + "apt-transport-https" + "ca-certificates" + "lsb-release" + ) + + for package in "${packages[@]}"; do + if apt-get install -y "$package"; then + success "Installed package: $package" + else + warning "Failed to install package: $package" + fi + done +} + +# Setup secrets directory +setup_secrets_directory() { + info "🔐 Setting up secrets directory..." + + local secrets_dir="/etc/hops/secrets" + + if mkdir -p "$secrets_dir"; then + chmod 700 "$secrets_dir" + success "Secrets directory created: $secrets_dir" + else + error_exit "Failed to create secrets directory" + fi +} + +# Configure system settings +configure_system() { + info "⚙️ Configuring system settings..." + + # Set timezone if not already set + if [[ -n "$TZ" ]]; then + timedatectl set-timezone "$TZ" 2>/dev/null || true + fi + + # Enable IP forwarding for Docker + echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf + sysctl -p /etc/sysctl.conf + + success "System configuration completed" +} + +# Main privileged setup +main() { + info "🚀 Starting privileged setup..." + + # System checks + detect_os + check_system_requirements + + # Install packages + install_packages + + # Install Docker + install_docker + + # Configure firewall + configure_firewall + + # Create directories + create_system_directories + + # Add user to docker group + add_user_to_docker_group + + # Setup secrets + setup_secrets_directory + + # Configure system + configure_system + + success "Privileged setup completed successfully" + success "Please log out and back in for group changes to take effect" +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi +EOF + + chmod +x "$setup_script" + success "Privileged setup script created: $setup_script" +} + +# Create non-privileged user script +create_user_script() { + local user_script="$1" + + cat > "$user_script" << 'EOF' +#!/bin/bash + +# HOPS User Script +# This script handles operations that can run as regular user +# Version: 3.1.0 + +set -e + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/docker.sh" +source "$SCRIPT_DIR/lib/validation.sh" + +# Initialize logging +setup_logging "user-operations" + +# Check if user is in docker group +check_docker_access() { + if ! groups "\$USER" | grep -q docker; then + error_exit "User not in docker group. Please run the privileged setup first and log out/in." + fi + + if ! docker info >/dev/null 2>&1; then + error_exit "Cannot access Docker daemon. Please ensure Docker is running." + fi +} + +# Generate Docker Compose configuration +generate_docker_compose() { + local services=("$@") + local compose_file="$HOME/homelab/docker-compose.yml" + + info "📝 Generating Docker Compose configuration..." + + # Create homelab directory + mkdir -p "$HOME/homelab" + + # Generate compose file header + cat > "$compose_file" << EOF +version: '3.8' + +services: +EOF + + # Generate service definitions + for service in "${services[@]}"; do + if "$SCRIPT_DIR/hops_service_definitions_improved.sh" generate "$service" >> "$compose_file"; then + success "Added service: $service" + else + error_exit "Failed to generate service definition for: $service" + fi + done + + # Add networks section + cat >> "$compose_file" << EOF + +networks: + homelab: + driver: bridge + traefik: + driver: bridge + database: + driver: bridge +EOF + + success "Docker Compose configuration generated: $compose_file" +} + +# Deploy services +deploy_services() { + local compose_file="$HOME/homelab/docker-compose.yml" + + if [[ ! -f "$compose_file" ]]; then + error_exit "Docker Compose file not found: $compose_file" + fi + + info "🚀 Deploying services..." + + cd "$HOME/homelab" + + # Pull images + if docker compose pull; then + success "Docker images pulled successfully" + else + error_exit "Failed to pull Docker images" + fi + + # Start services + if docker compose up -d; then + success "Services deployed successfully" + else + error_exit "Failed to deploy services" + fi +} + +# Stop services +stop_services() { + local compose_file="$HOME/homelab/docker-compose.yml" + + if [[ ! -f "$compose_file" ]]; then + error_exit "Docker Compose file not found: $compose_file" + fi + + info "🛑 Stopping services..." + + cd "$HOME/homelab" + + if docker compose down; then + success "Services stopped successfully" + else + error_exit "Failed to stop services" + fi +} + +# Show service status +show_service_status() { + local compose_file="$HOME/homelab/docker-compose.yml" + + if [[ ! -f "$compose_file" ]]; then + error_exit "Docker Compose file not found: $compose_file" + fi + + info "📊 Service status:" + + cd "$HOME/homelab" + docker compose ps +} + +# Main user operations +main() { + local action="$1" + shift + + # Check Docker access + check_docker_access + + case "$action" in + "generate") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 generate [service2] ..." + fi + generate_docker_compose "$@" + ;; + + "deploy") + deploy_services + ;; + + "stop") + stop_services + ;; + + "status") + show_service_status + ;; + + "logs") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 logs " + fi + cd "$HOME/homelab" + docker compose logs -f "$1" + ;; + + *) + error_exit "Unknown action: $action" + ;; + esac +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi +EOF + + chmod +x "$user_script" + success "User script created: $user_script" +} + +# Create installation wrapper +create_installation_wrapper() { + local wrapper_script="$1" + + cat > "$wrapper_script" << 'EOF' +#!/bin/bash + +# HOPS Installation Wrapper +# Orchestrates privileged and non-privileged installation steps +# Version: 3.1.0 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +# Initialize logging +setup_logging "installation-wrapper" + +# Show header +show_hops_header "3.1.0" "Installation Wrapper" + +# Check if we're running as root +if [[ $EUID -eq 0 ]]; then + if [[ -z "$SUDO_USER" ]]; then + error_exit "Please run with sudo, not as root directly" + fi +else + error_exit "This script must be run with sudo" +fi + +# Phase 1: Privileged setup +info "📋 Phase 1: Privileged setup (requires root)" +if "$SCRIPT_DIR/hops_privileged_setup.sh"; then + success "Privileged setup completed" +else + error_exit "Privileged setup failed" +fi + +# Phase 2: User setup +info "📋 Phase 2: User setup (running as $SUDO_USER)" + +# Drop privileges and run user setup +sudo -u "$SUDO_USER" bash << 'USERSCRIPT' +cd "$HOME" +echo "Running as user: $(whoami)" + +# Interactive service selection +echo "Select services to install:" +echo "1) Media Server Stack (Jellyfin, Sonarr, Radarr, Prowlarr)" +echo "2) Download Client Stack (qBittorrent, Transmission)" +echo "3) Monitoring Stack (Portainer, Uptime Kuma)" +echo "4) Custom selection" + +read -p "Enter your choice (1-4): " choice + +case "$choice" in + 1) + services=("jellyfin" "sonarr" "radarr" "prowlarr") + ;; + 2) + services=("qbittorrent" "transmission") + ;; + 3) + services=("portainer" "uptime-kuma") + ;; + 4) + echo "Available services:" + "$SCRIPT_DIR/hops_service_definitions_improved.sh" list + read -p "Enter service names (space-separated): " -a services + ;; + *) + echo "Invalid choice" + exit 1 + ;; +esac + +# Generate and deploy +if "$SCRIPT_DIR/hops_user_operations.sh" generate "${services[@]}"; then + echo "Configuration generated successfully" + + if "$SCRIPT_DIR/hops_user_operations.sh" deploy; then + echo "Services deployed successfully" + else + echo "Deployment failed" + exit 1 + fi +else + echo "Configuration generation failed" + exit 1 +fi +USERSCRIPT + +success "Installation completed successfully" +success "Services are now running. Check status with: ./hops_user_operations.sh status" +EOF + + chmod +x "$wrapper_script" + success "Installation wrapper created: $wrapper_script" +} + +# Main function +main() { + local action="$1" + shift + + case "$action" in + "create-setup") + create_privileged_setup "$1" + ;; + + "create-user") + create_user_script "$1" + ;; + + "create-wrapper") + create_installation_wrapper "$1" + ;; + + "create-all") + create_privileged_setup "hops_privileged_setup.sh" + create_user_script "hops_user_operations.sh" + create_installation_wrapper "hops_install.sh" + ;; + + "run") + local operation="$1" + local command="$2" + shift 2 + run_with_privileges "$operation" "$command" "$@" + ;; + + "help"|"--help"|"-h") + cat < [options] + +Actions: + create-setup Create privileged setup script + create-user Create non-privileged user script + create-wrapper Create installation wrapper + create-all Create all scripts + run [args] Run operation with appropriate privileges + help Show this help message + +Examples: + $0 create-all + $0 run install_docker /usr/bin/apt-get install docker-ce + $0 run generate_docker_compose ./compose-gen.sh + +EOF + ;; + + *) + error_exit "Unknown action: $action. Use 'help' for usage information." + ;; + esac +} + +# If script is run directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + setup_logging "privileges" + main "$@" +fi \ No newline at end of file diff --git a/lib/secrets.sh b/lib/secrets.sh new file mode 100755 index 0000000..0774061 --- /dev/null +++ b/lib/secrets.sh @@ -0,0 +1,577 @@ +#!/bin/bash + +# HOPS - Secret Management System +# Secure encryption and management of sensitive configuration data +# Version: 3.1.0 + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" +source "$SCRIPT_DIR/security.sh" + +# Default configuration +readonly SECRETS_DIR="/etc/hops/secrets" +readonly MASTER_KEY_FILE="$SECRETS_DIR/master.key" +readonly ENCRYPTED_ENV_FILE="$SECRETS_DIR/environment.gpg" +readonly DECRYPTED_ENV_FILE="/tmp/hops_env_$$" + +# Initialize secrets management +init_secrets() { + info "🔐 Initializing secrets management..." + + # Create secrets directory + if ! mkdir -p "$SECRETS_DIR"; then + error_exit "Failed to create secrets directory: $SECRETS_DIR" + fi + + # Set secure permissions + chmod 700 "$SECRETS_DIR" + + # Generate master key if it doesn't exist + if [[ ! -f "$MASTER_KEY_FILE" ]]; then + generate_master_key + fi + + success "Secrets management initialized" +} + +# Generate master encryption key +generate_master_key() { + info "🔑 Generating master encryption key..." + + # Generate 256-bit key + local master_key + master_key=$(openssl rand -hex 32) + + if [[ -z "$master_key" ]]; then + error_exit "Failed to generate master key" + fi + + # Store master key securely + echo "$master_key" > "$MASTER_KEY_FILE" + chmod 600 "$MASTER_KEY_FILE" + + success "Master key generated and stored securely" +} + +# Get master key +get_master_key() { + if [[ ! -f "$MASTER_KEY_FILE" ]]; then + error_exit "Master key file not found: $MASTER_KEY_FILE" + fi + + if [[ ! -r "$MASTER_KEY_FILE" ]]; then + error_exit "Cannot read master key file: $MASTER_KEY_FILE" + fi + + cat "$MASTER_KEY_FILE" +} + +# Encrypt environment file +encrypt_environment() { + local env_file="$1" + local output_file="${2:-$ENCRYPTED_ENV_FILE}" + + if [[ ! -f "$env_file" ]]; then + error_exit "Environment file not found: $env_file" + fi + + info "🔒 Encrypting environment file..." + + local master_key + master_key=$(get_master_key) + + # Encrypt using AES-256-GCM + if openssl enc -aes-256-gcm -salt -in "$env_file" -out "$output_file" -pass pass:"$master_key"; then + success "Environment file encrypted: $output_file" + + # Set secure permissions + chmod 600 "$output_file" + + # Optionally remove original + if confirm "Remove original plaintext file?" "y"; then + secure_delete "$env_file" + fi + else + error_exit "Failed to encrypt environment file" + fi +} + +# Decrypt environment file +decrypt_environment() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + local output_file="${2:-$DECRYPTED_ENV_FILE}" + + if [[ ! -f "$encrypted_file" ]]; then + error_exit "Encrypted file not found: $encrypted_file" + fi + + debug "Decrypting environment file..." + + local master_key + master_key=$(get_master_key) + + # Decrypt using AES-256-GCM + if openssl enc -aes-256-gcm -d -salt -in "$encrypted_file" -out "$output_file" -pass pass:"$master_key"; then + debug "Environment file decrypted: $output_file" + + # Set secure permissions + chmod 600 "$output_file" + + # Register for cleanup on exit + trap "secure_delete '$output_file'" EXIT + + echo "$output_file" + else + error_exit "Failed to decrypt environment file" + fi +} + +# Secure delete file +secure_delete() { + local file="$1" + + if [[ ! -f "$file" ]]; then + return 0 + fi + + debug "Securely deleting: $file" + + # Use shred if available, otherwise multiple overwrites + if command_exists shred; then + shred -vfz -n 3 "$file" 2>/dev/null + else + # Manual secure deletion + local file_size + file_size=$(stat -c%s "$file" 2>/dev/null || echo "0") + + if [[ "$file_size" -gt 0 ]]; then + # Overwrite with random data + dd if=/dev/urandom of="$file" bs="$file_size" count=1 2>/dev/null + # Overwrite with zeros + dd if=/dev/zero of="$file" bs="$file_size" count=1 2>/dev/null + fi + + rm -f "$file" + fi +} + +# Create encrypted environment file with secrets +create_encrypted_environment() { + local output_file="${1:-$ENCRYPTED_ENV_FILE}" + + info "🔐 Creating encrypted environment configuration..." + + # Generate secure passwords + local admin_password + local mysql_password + local postgres_password + local api_key + + admin_password=$(generate_secure_password 16) + mysql_password=$(generate_secure_password 20) + postgres_password=$(generate_secure_password 20) + api_key=$(generate_secure_password 32) + + # Get user input for configuration + local puid pgid timezone domain email data_root config_root + + puid=$(read_and_validate "Enter PUID (user ID)" "uid" "" "1000") + pgid=$(read_and_validate "Enter PGID (group ID)" "gid" "" "1000") + timezone=$(read_and_validate "Enter timezone" "timezone" "" "UTC") + domain=$(read_and_validate "Enter domain (optional)" "domain" "" "localhost" "true") + email=$(read_and_validate "Enter email for SSL certificates" "email" "" "" "false") + data_root=$(read_and_validate "Enter data root directory" "path" "true" "/mnt/media") + config_root=$(read_and_validate "Enter config root directory" "path" "true" "/opt/appdata") + + # Create temporary environment file + local temp_env_file="/tmp/hops_env_new_$$" + + cat > "$temp_env_file" << EOF +# HOPS Environment Configuration +# Generated on: $(date) +# Version: 3.1.0 + +# Core Configuration +PUID=$puid +PGID=$pgid +TZ=$timezone + +# Directory Configuration +DATA_ROOT=$data_root +CONFIG_ROOT=$config_root + +# Network Configuration +DOMAIN=$domain +ACME_EMAIL=$email + +# Security Configuration +DEFAULT_ADMIN_PASSWORD=$admin_password +MYSQL_ROOT_PASSWORD=$mysql_password +POSTGRES_PASSWORD=$postgres_password +API_KEY=$api_key + +# Service-specific passwords +JELLYFIN_PASSWORD=$(generate_secure_password 16) +PLEX_PASSWORD=$(generate_secure_password 16) +TRAEFIK_PASSWORD=$(generate_secure_password 16) +AUTHELIA_PASSWORD=$(generate_secure_password 16) + +# Database Configuration +MYSQL_DATABASE=homelab +MYSQL_USER=homelab +MYSQL_PASSWORD=$mysql_password + +POSTGRES_DB=homelab +POSTGRES_USER=homelab +POSTGRES_PASSWORD=$postgres_password + +# Optional: Cloudflare API (for DNS challenge) +CF_API_EMAIL= +CF_API_KEY= + +# Optional: Plex Claim Token +PLEX_CLAIM= + +# Optional: Advertise IP for Plex +ADVERTISE_IP= +EOF + + # Encrypt the environment file + encrypt_environment "$temp_env_file" "$output_file" + + # Clean up temporary file + secure_delete "$temp_env_file" + + success "Encrypted environment configuration created: $output_file" +} + +# Load encrypted environment into current shell +load_encrypted_environment() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + + if [[ ! -f "$encrypted_file" ]]; then + error_exit "Encrypted environment file not found: $encrypted_file" + fi + + debug "Loading encrypted environment..." + + # Decrypt to temporary file + local temp_env + temp_env=$(decrypt_environment "$encrypted_file") + + # Source the decrypted environment + set -a # Automatically export all variables + source "$temp_env" + set +a # Stop auto-export + + success "Environment loaded successfully" +} + +# Update encrypted environment +update_encrypted_environment() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + local key="$2" + local value="$3" + + if [[ -z "$key" || -z "$value" ]]; then + error_exit "Key and value are required for update" + fi + + info "🔄 Updating encrypted environment..." + + # Decrypt current environment + local temp_env + temp_env=$(decrypt_environment "$encrypted_file") + + # Create updated environment file + local updated_env="/tmp/hops_env_updated_$$" + + # Copy existing environment, updating the specified key + local key_found=false + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*${key}[[:space:]]*= ]]; then + echo "$key=$value" + key_found=true + else + echo "$line" + fi + done < "$temp_env" > "$updated_env" + + # If key wasn't found, add it + if [[ "$key_found" != "true" ]]; then + echo "$key=$value" >> "$updated_env" + fi + + # Encrypt updated environment + encrypt_environment "$updated_env" "$encrypted_file" + + # Clean up temporary files + secure_delete "$temp_env" + secure_delete "$updated_env" + + success "Environment updated successfully" +} + +# Get value from encrypted environment +get_encrypted_value() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + local key="$2" + + if [[ -z "$key" ]]; then + error_exit "Key is required" + fi + + # Decrypt environment + local temp_env + temp_env=$(decrypt_environment "$encrypted_file") + + # Get value + local value + value=$(grep "^${key}=" "$temp_env" | cut -d= -f2- | tr -d '"') + + # Clean up + secure_delete "$temp_env" + + echo "$value" +} + +# List all keys in encrypted environment +list_encrypted_keys() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + + info "📋 Environment configuration keys:" + + # Decrypt environment + local temp_env + temp_env=$(decrypt_environment "$encrypted_file") + + # List keys (exclude comments and empty lines) + grep -E "^[A-Za-z_][A-Za-z0-9_]*=" "$temp_env" | cut -d= -f1 | sort + + # Clean up + secure_delete "$temp_env" +} + +# Backup encrypted environment +backup_encrypted_environment() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + local backup_file="${2:-$SECRETS_DIR/environment_backup_$(date +%Y%m%d_%H%M%S).gpg}" + + if [[ ! -f "$encrypted_file" ]]; then + error_exit "Encrypted environment file not found: $encrypted_file" + fi + + info "💾 Creating backup of encrypted environment..." + + if cp "$encrypted_file" "$backup_file"; then + chmod 600 "$backup_file" + success "Backup created: $backup_file" + echo "$backup_file" + else + error_exit "Failed to create backup" + fi +} + +# Restore encrypted environment from backup +restore_encrypted_environment() { + local backup_file="$1" + local target_file="${2:-$ENCRYPTED_ENV_FILE}" + + if [[ ! -f "$backup_file" ]]; then + error_exit "Backup file not found: $backup_file" + fi + + if [[ -f "$target_file" ]]; then + if ! confirm "Overwrite existing environment file?" "n"; then + info "Restore cancelled" + return 0 + fi + fi + + info "📦 Restoring encrypted environment from backup..." + + if cp "$backup_file" "$target_file"; then + chmod 600 "$target_file" + success "Environment restored from backup" + else + error_exit "Failed to restore from backup" + fi +} + +# Change master key (re-encrypt all data) +change_master_key() { + local old_key_file="$MASTER_KEY_FILE" + local new_key_file="${MASTER_KEY_FILE}.new" + + if [[ ! -f "$old_key_file" ]]; then + error_exit "Master key file not found: $old_key_file" + fi + + warning "Changing master key will re-encrypt all stored secrets" + if ! confirm "Continue?" "n"; then + info "Master key change cancelled" + return 0 + fi + + info "🔑 Changing master key..." + + # Backup current encrypted environment + local backup_file + backup_file=$(backup_encrypted_environment) + + # Decrypt current environment + local temp_env + temp_env=$(decrypt_environment) + + # Generate new master key + local new_master_key + new_master_key=$(openssl rand -hex 32) + echo "$new_master_key" > "$new_key_file" + chmod 600 "$new_key_file" + + # Move new key to replace old key + mv "$new_key_file" "$old_key_file" + + # Re-encrypt environment with new key + encrypt_environment "$temp_env" "$ENCRYPTED_ENV_FILE" + + # Clean up + secure_delete "$temp_env" + + success "Master key changed successfully" + success "Backup created: $backup_file" +} + +# Verify encrypted environment integrity +verify_encrypted_environment() { + local encrypted_file="${1:-$ENCRYPTED_ENV_FILE}" + + info "🔍 Verifying encrypted environment integrity..." + + # Try to decrypt + local temp_env + if temp_env=$(decrypt_environment "$encrypted_file" 2>/dev/null); then + # Verify it's a valid environment file + if grep -q "^PUID=" "$temp_env" && grep -q "^PGID=" "$temp_env"; then + success "Environment file integrity verified" + secure_delete "$temp_env" + return 0 + else + error_exit "Decrypted file is not a valid environment file" + fi + else + error_exit "Failed to decrypt environment file - possible corruption" + fi +} + +# Main function for command line usage +main() { + local action="$1" + shift + + case "$action" in + "init") + init_secrets + ;; + + "create") + create_encrypted_environment "$@" + ;; + + "encrypt") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 encrypt [output_file]" + fi + encrypt_environment "$@" + ;; + + "decrypt") + decrypt_environment "$@" + ;; + + "update") + if [[ $# -lt 2 ]]; then + error_exit "Usage: $0 update [file]" + fi + update_encrypted_environment "${3:-$ENCRYPTED_ENV_FILE}" "$1" "$2" + ;; + + "get") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 get [file]" + fi + get_encrypted_value "${2:-$ENCRYPTED_ENV_FILE}" "$1" + ;; + + "list") + list_encrypted_keys "$@" + ;; + + "backup") + backup_encrypted_environment "$@" + ;; + + "restore") + if [[ $# -eq 0 ]]; then + error_exit "Usage: $0 restore [target_file]" + fi + restore_encrypted_environment "$@" + ;; + + "change-key") + change_master_key + ;; + + "verify") + verify_encrypted_environment "$@" + ;; + + "help"|"--help"|"-h") + cat < [options] + +Actions: + init Initialize secrets management + create Create new encrypted environment + encrypt Encrypt environment file + decrypt [encrypted_file] Decrypt environment file + update Update environment value + get Get environment value + list List all environment keys + backup Backup encrypted environment + restore Restore from backup + change-key Change master encryption key + verify Verify environment integrity + help Show this help message + +Examples: + $0 init + $0 create + $0 encrypt /path/to/.env + $0 update DOMAIN example.com + $0 get PUID + $0 backup + $0 verify + +EOF + ;; + + *) + error_exit "Unknown action: $action. Use 'help' for usage information." + ;; + esac +} + +# If script is run directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + # Initialize logging + setup_logging "secrets" + + # Require root for secrets management + check_root + + main "$@" +fi \ No newline at end of file diff --git a/lib/security.sh b/lib/security.sh new file mode 100644 index 0000000..2bcab67 --- /dev/null +++ b/lib/security.sh @@ -0,0 +1,450 @@ +#!/bin/bash + +# HOPS - Security Functions +# Password generation, validation, and security utilities +# Version: 3.1.0 + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Password validation +validate_password() { + local password="$1" + local min_length="${2:-12}" + + # Check minimum length + if [[ ${#password} -lt $min_length ]]; then + debug "Password too short: ${#password} < $min_length" + return 1 + fi + + # Check for uppercase letter + if [[ ! "$password" =~ [A-Z] ]]; then + debug "Password missing uppercase letter" + return 2 + fi + + # Check for lowercase letter + if [[ ! "$password" =~ [a-z] ]]; then + debug "Password missing lowercase letter" + return 2 + fi + + # Check for digit + if [[ ! "$password" =~ [0-9] ]]; then + debug "Password missing digit" + return 2 + fi + + # Check for special character + if [[ ! "$password" =~ [^A-Za-z0-9] ]]; then + debug "Password missing special character" + return 2 + fi + + return 0 +} + +# Generate secure password +generate_secure_password() { + local length="${1:-16}" + local max_attempts=10 + + debug "Generating secure password of length $length" + + # Try OpenSSL method first + for ((attempt=1; attempt<=max_attempts; attempt++)); do + local password + + if command_exists openssl; then + password=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-${length}) + else + # Fallback to /dev/urandom + password=$(tr -dc 'A-Za-z0-9!@#$%^&*' < /dev/urandom | head -c${length}) + fi + + if validate_password "$password" "$length"; then + echo "$password" + return 0 + fi + + debug "Password attempt $attempt failed validation" + done + + # Fallback: construct guaranteed compliant password + debug "Using fallback password generation method" + + local upper=$(tr -dc 'A-Z' < /dev/urandom | head -c2) + local lower=$(tr -dc 'a-z' < /dev/urandom | head -c4) + local digits=$(tr -dc '0-9' < /dev/urandom | head -c2) + local symbols=$(tr -dc '!@#$%^&*' < /dev/urandom | head -c2) + local remaining_length=$((length - 10)) + + local password="" + + if [[ $remaining_length -gt 0 ]]; then + local remaining=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c$remaining_length) + password="${upper}${lower}${digits}${symbols}${remaining}" + else + password="${upper}${lower}${digits}${symbols}" + fi + + # Shuffle the password + password=$(echo "$password" | fold -w1 | shuf | tr -d '\n') + + echo "$password" +} + +# Generate multiple passwords for services +generate_service_passwords() { + local -A passwords + + info "🔐 Generating secure passwords for services..." + + # Admin password (16 chars) + passwords["admin"]=$(generate_secure_password 16) + + # Database passwords (20 chars) + passwords["mysql_root"]=$(generate_secure_password 20) + passwords["postgres"]=$(generate_secure_password 20) + + # Service-specific passwords (16 chars) + passwords["jellyfin"]=$(generate_secure_password 16) + passwords["plex"]=$(generate_secure_password 16) + passwords["traefik"]=$(generate_secure_password 16) + passwords["authelia"]=$(generate_secure_password 16) + + # API keys (32 chars) + passwords["api_key"]=$(generate_secure_password 32) + + # Return as key=value pairs + for key in "${!passwords[@]}"; do + echo "${key}=${passwords[$key]}" + done +} + +# Encrypt string using GPG +encrypt_string() { + local plaintext="$1" + local passphrase="$2" + + if [[ -z "$plaintext" || -z "$passphrase" ]]; then + error_exit "encrypt_string requires plaintext and passphrase" + fi + + if ! command_exists gpg; then + error_exit "GPG not available for encryption" + fi + + echo "$plaintext" | gpg --batch --yes --passphrase "$passphrase" --symmetric --cipher-algo AES256 --armor +} + +# Decrypt string using GPG +decrypt_string() { + local encrypted="$1" + local passphrase="$2" + + if [[ -z "$encrypted" || -z "$passphrase" ]]; then + error_exit "decrypt_string requires encrypted text and passphrase" + fi + + if ! command_exists gpg; then + error_exit "GPG not available for decryption" + fi + + echo "$encrypted" | gpg --batch --yes --passphrase "$passphrase" --decrypt --quiet +} + +# Create encrypted .env file +create_encrypted_env() { + local env_file="$1" + local master_password="$2" + local encrypted_file="${env_file}.gpg" + + if [[ ! -f "$env_file" ]]; then + error_exit "Environment file not found: $env_file" + fi + + if [[ -z "$master_password" ]]; then + error_exit "Master password required for encryption" + fi + + info "🔐 Encrypting environment file..." + + if gpg --batch --yes --passphrase "$master_password" --symmetric --cipher-algo AES256 --armor --output "$encrypted_file" "$env_file"; then + success "Environment file encrypted: $encrypted_file" + + # Securely remove original + if confirm "Remove original plaintext file?" "y"; then + shred -vfz -n 3 "$env_file" 2>/dev/null || rm -f "$env_file" + success "Original file securely removed" + fi + else + error_exit "Failed to encrypt environment file" + fi +} + +# Decrypt .env file +decrypt_env() { + local encrypted_file="$1" + local master_password="$2" + local output_file="${encrypted_file%.gpg}" + + if [[ ! -f "$encrypted_file" ]]; then + error_exit "Encrypted file not found: $encrypted_file" + fi + + if [[ -z "$master_password" ]]; then + error_exit "Master password required for decryption" + fi + + info "🔓 Decrypting environment file..." + + if gpg --batch --yes --passphrase "$master_password" --decrypt --output "$output_file" "$encrypted_file"; then + success "Environment file decrypted: $output_file" + + # Set secure permissions + chmod 600 "$output_file" + else + error_exit "Failed to decrypt environment file" + fi +} + +# Setup file permissions +setup_file_permissions() { + local target_dir="$1" + + if [[ ! -d "$target_dir" ]]; then + error_exit "Target directory does not exist: $target_dir" + fi + + info "🔒 Setting up secure file permissions..." + + # Set directory permissions + chmod 750 "$target_dir" + + # Find and secure sensitive files + local sensitive_patterns=("*.env" "*.key" "*.pem" "*.crt" "*.conf" "*.yml" "*.yaml") + + for pattern in "${sensitive_patterns[@]}"; do + find "$target_dir" -name "$pattern" -type f -exec chmod 600 {} \; 2>/dev/null || true + done + + # Set ownership if running as sudo + if [[ -n "$SUDO_USER" ]]; then + local user_info + user_info=$(get_user_info) + + local uid=$(echo "$user_info" | grep "uid=" | cut -d= -f2) + local gid=$(echo "$user_info" | grep "gid=" | cut -d= -f2) + + chown -R "$uid:$gid" "$target_dir" + fi + + success "File permissions configured" +} + +# Generate SSL certificate +generate_ssl_certificate() { + local domain="$1" + local cert_dir="$2" + local key_file="$cert_dir/$domain.key" + local cert_file="$cert_dir/$domain.crt" + + if [[ -z "$domain" || -z "$cert_dir" ]]; then + error_exit "generate_ssl_certificate requires domain and cert_dir" + fi + + if ! command_exists openssl; then + error_exit "OpenSSL not available for certificate generation" + fi + + mkdir -p "$cert_dir" + + info "🔐 Generating SSL certificate for $domain..." + + # Generate private key + if openssl genrsa -out "$key_file" 2048 >/dev/null 2>&1; then + chmod 600 "$key_file" + success "Private key generated: $key_file" + else + error_exit "Failed to generate private key" + fi + + # Generate certificate + if openssl req -new -x509 -key "$key_file" -out "$cert_file" -days 365 -subj "/CN=$domain" >/dev/null 2>&1; then + chmod 644 "$cert_file" + success "Certificate generated: $cert_file" + else + error_exit "Failed to generate certificate" + fi +} + +# Validate input path (prevent path traversal) +validate_path() { + local path="$1" + local allow_relative="${2:-false}" + + if [[ -z "$path" ]]; then + return 1 + fi + + # Check for path traversal attempts + if [[ "$path" =~ \.\./|\.\.\\ ]]; then + debug "Path traversal attempt detected: $path" + return 1 + fi + + # Check for null bytes + if [[ "$path" =~ $'\0' ]]; then + debug "Null byte detected in path: $path" + return 1 + fi + + # Check if relative paths are allowed + if [[ "$allow_relative" != "true" && "$path" != /* ]]; then + debug "Relative path not allowed: $path" + return 1 + fi + + return 0 +} + +# Sanitize filename +sanitize_filename() { + local filename="$1" + + # Remove path separators and dangerous characters + filename=$(echo "$filename" | tr -d '/' | tr -d '\\' | tr -d '..' | tr -d '\0') + + # Remove leading/trailing whitespace + filename=$(echo "$filename" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + # Limit length + if [[ ${#filename} -gt 255 ]]; then + filename="${filename:0:255}" + fi + + echo "$filename" +} + +# Validate service name +validate_service_name() { + local service_name="$1" + + if [[ -z "$service_name" ]]; then + return 1 + fi + + # Check for valid characters (alphanumeric, hyphens, underscores) + if [[ ! "$service_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then + return 1 + fi + + # Check length + if [[ ${#service_name} -gt 63 ]]; then + return 1 + fi + + return 0 +} + +# Check for common security issues +security_audit() { + local target_dir="$1" + local issues=() + + info "🔍 Performing security audit..." + + # Check for world-writable files + if find "$target_dir" -type f -perm -002 2>/dev/null | head -n1 | read -r; then + issues+=("World-writable files found") + fi + + # Check for SUID/SGID files + if find "$target_dir" -type f \( -perm -4000 -o -perm -2000 \) 2>/dev/null | head -n1 | read -r; then + issues+=("SUID/SGID files found") + fi + + # Check for empty passwords in .env files + if find "$target_dir" -name "*.env" -type f -exec grep -l "PASSWORD=\|PASS=\|SECRET=" {} \; 2>/dev/null | head -n1 | read -r; then + if find "$target_dir" -name "*.env" -type f -exec grep -l "PASSWORD=\s*$\|PASS=\s*$\|SECRET=\s*$" {} \; 2>/dev/null | head -n1 | read -r; then + issues+=("Empty passwords found in .env files") + fi + fi + + # Check for default credentials + local default_patterns=("password\|admin\|root\|123456\|password123") + for pattern in "${default_patterns[@]}"; do + if find "$target_dir" -name "*.env" -type f -exec grep -il "$pattern" {} \; 2>/dev/null | head -n1 | read -r; then + issues+=("Potential default credentials found") + break + fi + done + + # Report issues + if [[ ${#issues[@]} -gt 0 ]]; then + warning "Security issues found:" + for issue in "${issues[@]}"; do + warning " • $issue" + done + return 1 + else + success "No security issues detected" + return 0 + fi +} + +# Create backup with encryption +create_encrypted_backup() { + local source_dir="$1" + local backup_file="$2" + local password="$3" + + if [[ ! -d "$source_dir" ]]; then + error_exit "Source directory does not exist: $source_dir" + fi + + if [[ -z "$password" ]]; then + error_exit "Password required for encrypted backup" + fi + + info "💾 Creating encrypted backup..." + + # Create tar archive and encrypt + if tar -czf - "$source_dir" | gpg --batch --yes --passphrase "$password" --symmetric --cipher-algo AES256 --armor > "$backup_file"; then + success "Encrypted backup created: $backup_file" + + # Set secure permissions + chmod 600 "$backup_file" + else + error_exit "Failed to create encrypted backup" + fi +} + +# Restore from encrypted backup +restore_encrypted_backup() { + local backup_file="$1" + local restore_dir="$2" + local password="$3" + + if [[ ! -f "$backup_file" ]]; then + error_exit "Backup file does not exist: $backup_file" + fi + + if [[ -z "$password" ]]; then + error_exit "Password required for backup restoration" + fi + + info "📦 Restoring from encrypted backup..." + + if gpg --batch --yes --passphrase "$password" --decrypt "$backup_file" | tar -xzf - -C "$restore_dir"; then + success "Backup restored to: $restore_dir" + + # Set secure permissions + setup_file_permissions "$restore_dir" + else + error_exit "Failed to restore encrypted backup" + fi +} \ No newline at end of file diff --git a/lib/system.sh b/lib/system.sh new file mode 100644 index 0000000..48dfd16 --- /dev/null +++ b/lib/system.sh @@ -0,0 +1,318 @@ +#!/bin/bash + +# HOPS - System Validation Functions +# Functions for system checks, OS detection, and requirements validation +# Version: 3.1.0 + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Global variables for system info +OS_NAME="" +OS_VERSION="" +OS_NAME_LOWER="" + +# Detect operating system +detect_os() { + info "🔍 Detecting operating system..." + + if command_exists lsb_release; then + OS_NAME=$(lsb_release -is) + OS_VERSION=$(lsb_release -rs) + elif [[ -f /etc/os-release ]]; then + OS_NAME=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"') + OS_VERSION=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"') + else + error_exit "Unable to detect operating system" + fi + + OS_NAME_LOWER=$(echo "$OS_NAME" | tr '[:upper:]' '[:lower:]') + + # Validate supported OS + case "$OS_NAME_LOWER" in + ubuntu|debian|linuxmint|mint) + success "Detected supported OS: $OS_NAME $OS_VERSION" + ;; + *) + error_exit "Unsupported OS: $OS_NAME $OS_VERSION. Only Ubuntu/Debian/Linux Mint are supported." + ;; + esac +} + +# Check system requirements +check_system_requirements() { + local min_ram_gb=${1:-2} + local min_disk_gb=${2:-10} + local target_dir="${3:-/}" + + info "🔍 Checking system requirements..." + + # Check architecture + local arch=$(uname -m) + if [[ "$arch" != "x86_64" ]]; then + error_exit "Unsupported architecture: $arch. Only x86_64 is supported." + fi + + # Check RAM + local ram_gb + if command_exists free; then + ram_gb=$(free -g | awk '/^Mem:/{print $2}') + else + ram_gb=$(awk '/MemTotal/ {print int($2/1024/1024)}' /proc/meminfo) + fi + + if [[ $ram_gb -lt $min_ram_gb ]]; then + error_exit "Insufficient RAM: ${ram_gb}GB detected, ${min_ram_gb}GB required" + fi + + # Check disk space + local disk_avail_gb + if command_exists df; then + disk_avail_gb=$(df -BG --output=avail "$target_dir" | tail -n 1 | tr -d 'G') + else + error_exit "Unable to check disk space - 'df' command not available" + fi + + if [[ $disk_avail_gb -lt $min_disk_gb ]]; then + error_exit "Insufficient disk space: ${disk_avail_gb}GB available in $target_dir, ${min_disk_gb}GB required" + fi + + # Check CPU cores + local cpu_cores=$(nproc) + if [[ $cpu_cores -lt 2 ]]; then + warning "Only ${cpu_cores} CPU core(s) detected. 2+ cores recommended for optimal performance." + fi + + success "System requirements met: ${ram_gb}GB RAM, ${disk_avail_gb}GB disk space, ${cpu_cores} CPU cores" +} + +# Check if running in a container +check_container_environment() { + if [[ -f /.dockerenv ]] || grep -q 'container=docker' /proc/1/environ 2>/dev/null; then + warning "Running inside a container. Some features may not work correctly." + return 0 + fi + return 1 +} + +# Check internet connectivity +check_internet() { + local test_urls=( + "google.com" + "github.com" + "docker.com" + ) + + info "🌐 Checking internet connectivity..." + + for url in "${test_urls[@]}"; do + if ping -c 1 -W 5 "$url" >/dev/null 2>&1; then + success "Internet connectivity verified" + return 0 + fi + done + + error_exit "No internet connectivity detected. Please check your network connection." +} + +# Check Docker requirements +check_docker_requirements() { + info "🐳 Checking Docker requirements..." + + # Check if Docker is installed + if ! command_exists docker; then + warning "Docker not installed. Will be installed automatically." + return 1 + fi + + # Check if Docker daemon is running + if ! docker info >/dev/null 2>&1; then + warning "Docker daemon not running. Will be started automatically." + return 1 + fi + + # Check Docker version + local docker_version=$(docker --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1) + local min_version="20.10.0" + + if ! version_compare "$docker_version" "$min_version"; then + error_exit "Docker version $docker_version is too old. Minimum required: $min_version" + fi + + # Check Docker Compose + if ! docker compose version >/dev/null 2>&1; then + error_exit "Docker Compose not available. Please install Docker Compose v2+" + fi + + success "Docker requirements met" + return 0 +} + +# Compare version strings (returns 0 if version1 >= version2) +version_compare() { + local version1="$1" + local version2="$2" + + # Convert versions to arrays + local IFS='.' + local -a ver1=($version1) + local -a ver2=($version2) + + # Compare each component + for i in {0..2}; do + local v1=${ver1[$i]:-0} + local v2=${ver2[$i]:-0} + + if [[ $v1 -gt $v2 ]]; then + return 0 + elif [[ $v1 -lt $v2 ]]; then + return 1 + fi + done + + return 0 +} + +# Check if user has sudo privileges +check_sudo() { + if [[ $EUID -eq 0 ]]; then + return 0 + fi + + if ! sudo -n true 2>/dev/null; then + error_exit "This script requires sudo privileges. Please run with sudo or as root." + fi + + return 0 +} + +# Get system timezone +get_system_timezone() { + if [[ -f /etc/timezone ]]; then + cat /etc/timezone + elif [[ -L /etc/localtime ]]; then + readlink /etc/localtime | sed 's|/usr/share/zoneinfo/||' + else + timedatectl show --property=Timezone --value 2>/dev/null || echo "UTC" + fi +} + +# Validate timezone +validate_timezone() { + local timezone="$1" + + if [[ -z "$timezone" ]]; then + return 1 + fi + + if [[ -f "/usr/share/zoneinfo/$timezone" ]]; then + return 0 + fi + + return 1 +} + +# Check available storage space for specific path +check_storage_space() { + local path="$1" + local required_gb="$2" + + # Create directory if it doesn't exist + mkdir -p "$path" 2>/dev/null || true + + local available_gb=$(df -BG --output=avail "$path" | tail -n 1 | tr -d 'G') + + if [[ $available_gb -lt $required_gb ]]; then + error_exit "Insufficient storage space in $path: ${available_gb}GB available, ${required_gb}GB required" + fi + + success "Storage space check passed: ${available_gb}GB available in $path" +} + +# Check if directory is writable +check_directory_writable() { + local dir="$1" + + # Try to create directory if it doesn't exist + if ! mkdir -p "$dir" 2>/dev/null; then + error_exit "Cannot create directory: $dir" + fi + + # Check if writable + if ! [[ -w "$dir" ]]; then + error_exit "Directory not writable: $dir" + fi + + return 0 +} + +# Get current user info (handles sudo correctly) +get_user_info() { + local -A user_info + + if [[ -n "$SUDO_USER" ]]; then + user_info["username"]="$SUDO_USER" + user_info["uid"]=$(id -u "$SUDO_USER") + user_info["gid"]=$(id -g "$SUDO_USER") + user_info["home"]=$(eval echo "~$SUDO_USER") + else + user_info["username"]="$USER" + user_info["uid"]=$(id -u) + user_info["gid"]=$(id -g) + user_info["home"]="$HOME" + fi + + # Return as key=value pairs + for key in "${!user_info[@]}"; do + echo "${key}=${user_info[$key]}" + done +} + +# Check if firewall is available and configured +check_firewall() { + info "🔥 Checking firewall status..." + + if command_exists ufw; then + local ufw_status=$(ufw status | head -n1 | awk '{print $2}') + + case "$ufw_status" in + "active") + success "UFW firewall is active" + return 0 + ;; + "inactive") + warning "UFW firewall is inactive. Will be configured automatically." + return 1 + ;; + *) + warning "UFW firewall status unknown: $ufw_status" + return 1 + ;; + esac + else + warning "UFW not installed. Will be installed automatically." + return 1 + fi +} + +# Comprehensive system check +run_system_checks() { + local min_ram_gb=${1:-2} + local min_disk_gb=${2:-10} + local target_dir="${3:-/}" + + info "🔍 Running comprehensive system checks..." + + check_root + detect_os + check_system_requirements "$min_ram_gb" "$min_disk_gb" "$target_dir" + check_internet + check_docker_requirements + check_firewall + + # Check for container environment (warning only) + check_container_environment + + success "All system checks passed" +} \ No newline at end of file diff --git a/lib/validation.sh b/lib/validation.sh new file mode 100644 index 0000000..2b7ba37 --- /dev/null +++ b/lib/validation.sh @@ -0,0 +1,588 @@ +#!/bin/bash + +# HOPS - Input Validation and Sanitization Functions +# Comprehensive input validation and sanitization utilities +# Version: 3.1.0 + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Validate and sanitize directory path +validate_directory_path() { + local path="$1" + local allow_relative="${2:-false}" + local create_if_missing="${3:-false}" + + if [[ -z "$path" ]]; then + error_exit "Directory path cannot be empty" + fi + + # Remove any trailing slashes (except for root) + path="${path%/}" + if [[ "$path" == "" ]]; then + path="/" + fi + + # Check for path traversal attempts + if [[ "$path" =~ \.\./|\.\.\\ ]]; then + error_exit "Path traversal detected in: $path" + fi + + # Check for null bytes + if [[ "$path" =~ $'\0' ]]; then + error_exit "Null byte detected in path: $path" + fi + + # Check for dangerous characters + if [[ "$path" =~ [\;\&\|\`\$\(\)] ]]; then + error_exit "Dangerous characters detected in path: $path" + fi + + # Check if relative paths are allowed + if [[ "$allow_relative" != "true" && "$path" != /* ]]; then + error_exit "Relative paths not allowed: $path" + fi + + # Validate length (most filesystems have a 4096 limit) + if [[ ${#path} -gt 4000 ]]; then + error_exit "Path too long: ${#path} characters (max 4000)" + fi + + # Create directory if requested and doesn't exist + if [[ "$create_if_missing" == "true" && ! -d "$path" ]]; then + if ! mkdir -p "$path" 2>/dev/null; then + error_exit "Failed to create directory: $path" + fi + fi + + # Return sanitized path + echo "$path" +} + +# Validate timezone +validate_timezone() { + local timezone="$1" + + if [[ -z "$timezone" ]]; then + error_exit "Timezone cannot be empty" + fi + + # Basic format validation + if [[ ! "$timezone" =~ ^[A-Za-z_]+(/[A-Za-z_]+)*$ ]]; then + error_exit "Invalid timezone format: $timezone" + fi + + # Check if timezone file exists + if [[ ! -f "/usr/share/zoneinfo/$timezone" ]]; then + error_exit "Unknown timezone: $timezone" + fi + + echo "$timezone" +} + +# Validate domain name +validate_domain() { + local domain="$1" + + if [[ -z "$domain" ]]; then + error_exit "Domain cannot be empty" + fi + + # Basic domain validation + if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]; then + error_exit "Invalid domain format: $domain" + fi + + # Check length + if [[ ${#domain} -gt 253 ]]; then + error_exit "Domain too long: ${#domain} characters (max 253)" + fi + + # Check for localhost variants + if [[ "$domain" =~ ^(localhost|127\.0\.0\.1|::1)$ ]]; then + warning "Using localhost domain: $domain" + fi + + echo "$domain" +} + +# Validate email address +validate_email() { + local email="$1" + + if [[ -z "$email" ]]; then + error_exit "Email cannot be empty" + fi + + # Basic email validation + if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + error_exit "Invalid email format: $email" + fi + + # Check length + if [[ ${#email} -gt 254 ]]; then + error_exit "Email too long: ${#email} characters (max 254)" + fi + + echo "$email" +} + +# Validate port number +validate_port() { + local port="$1" + local allow_privileged="${2:-false}" + + if [[ -z "$port" ]]; then + error_exit "Port cannot be empty" + fi + + # Check if it's a number + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + error_exit "Port must be a number: $port" + fi + + # Check range + if [[ "$port" -lt 1 || "$port" -gt 65535 ]]; then + error_exit "Port out of range: $port (1-65535)" + fi + + # Check for privileged ports + if [[ "$allow_privileged" != "true" && "$port" -lt 1024 ]]; then + error_exit "Privileged port not allowed: $port (use ports >= 1024)" + fi + + echo "$port" +} + +# Validate IP address +validate_ip() { + local ip="$1" + + if [[ -z "$ip" ]]; then + error_exit "IP address cannot be empty" + fi + + # IPv4 validation + if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + local IFS='.' + local -a octets=($ip) + + for octet in "${octets[@]}"; do + if [[ "$octet" -gt 255 ]]; then + error_exit "Invalid IPv4 address: $ip" + fi + done + + echo "$ip" + return 0 + fi + + # IPv6 validation (basic) + if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]]; then + echo "$ip" + return 0 + fi + + error_exit "Invalid IP address format: $ip" +} + +# Validate container name +validate_container_name() { + local name="$1" + + if [[ -z "$name" ]]; then + error_exit "Container name cannot be empty" + fi + + # Docker container name validation + if [[ ! "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then + error_exit "Invalid container name: $name (use alphanumeric, underscore, period, hyphen)" + fi + + # Check length + if [[ ${#name} -gt 63 ]]; then + error_exit "Container name too long: ${#name} characters (max 63)" + fi + + echo "$name" +} + +# Validate environment variable name +validate_env_var_name() { + local name="$1" + + if [[ -z "$name" ]]; then + error_exit "Environment variable name cannot be empty" + fi + + # Environment variable name validation + if [[ ! "$name" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + error_exit "Invalid environment variable name: $name" + fi + + echo "$name" +} + +# Validate and sanitize environment variable value +validate_env_var_value() { + local value="$1" + local allow_empty="${2:-false}" + + if [[ -z "$value" && "$allow_empty" != "true" ]]; then + error_exit "Environment variable value cannot be empty" + fi + + # Check for null bytes + if [[ "$value" =~ $'\0' ]]; then + error_exit "Null byte detected in environment variable value" + fi + + # Check for dangerous command substitution + if [[ "$value" =~ \$\(|\`|\\x ]]; then + error_exit "Dangerous command substitution detected in value: $value" + fi + + echo "$value" +} + +# Validate user ID +validate_uid() { + local uid="$1" + + if [[ -z "$uid" ]]; then + error_exit "UID cannot be empty" + fi + + # Check if it's a number + if [[ ! "$uid" =~ ^[0-9]+$ ]]; then + error_exit "UID must be a number: $uid" + fi + + # Check range (0-65534) + if [[ "$uid" -lt 0 || "$uid" -gt 65534 ]]; then + error_exit "UID out of range: $uid (0-65534)" + fi + + # Warn about using root + if [[ "$uid" -eq 0 ]]; then + warning "Using root UID (0) is not recommended" + fi + + echo "$uid" +} + +# Validate group ID +validate_gid() { + local gid="$1" + + if [[ -z "$gid" ]]; then + error_exit "GID cannot be empty" + fi + + # Check if it's a number + if [[ ! "$gid" =~ ^[0-9]+$ ]]; then + error_exit "GID must be a number: $gid" + fi + + # Check range (0-65534) + if [[ "$gid" -lt 0 || "$gid" -gt 65534 ]]; then + error_exit "GID out of range: $gid (0-65534)" + fi + + # Warn about using root + if [[ "$gid" -eq 0 ]]; then + warning "Using root GID (0) is not recommended" + fi + + echo "$gid" +} + +# Validate memory size (e.g., "512m", "2g") +validate_memory_size() { + local size="$1" + + if [[ -z "$size" ]]; then + error_exit "Memory size cannot be empty" + fi + + # Check format (number followed by unit) + if [[ ! "$size" =~ ^[0-9]+[kmgtKMGT]?$ ]]; then + error_exit "Invalid memory size format: $size (use format like 512m, 2g)" + fi + + echo "$size" +} + +# Validate disk size (e.g., "10G", "500M") +validate_disk_size() { + local size="$1" + + if [[ -z "$size" ]]; then + error_exit "Disk size cannot be empty" + fi + + # Check format (number followed by unit) + if [[ ! "$size" =~ ^[0-9]+[KMGTPEZY]?$ ]]; then + error_exit "Invalid disk size format: $size (use format like 10G, 500M)" + fi + + echo "$size" +} + +# Validate URL +validate_url() { + local url="$1" + local allowed_schemes="${2:-http,https}" + + if [[ -z "$url" ]]; then + error_exit "URL cannot be empty" + fi + + # Basic URL validation + if [[ ! "$url" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then + error_exit "Invalid URL format: $url" + fi + + # Extract scheme + local scheme="${url%%://*}" + + # Check allowed schemes + if [[ ",$allowed_schemes," != *",$scheme,"* ]]; then + error_exit "URL scheme not allowed: $scheme (allowed: $allowed_schemes)" + fi + + echo "$url" +} + +# Sanitize filename for safe use +sanitize_filename() { + local filename="$1" + local max_length="${2:-255}" + + if [[ -z "$filename" ]]; then + error_exit "Filename cannot be empty" + fi + + # Remove path separators and dangerous characters + filename=$(echo "$filename" | tr -d '/' | tr -d '\\' | tr -d '\0') + + # Remove control characters + filename=$(echo "$filename" | tr -d '[:cntrl:]') + + # Remove leading/trailing whitespace + filename=$(echo "$filename" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + # Replace multiple spaces with single space + filename=$(echo "$filename" | sed 's/[[:space:]]\+/ /g') + + # Remove reserved names (Windows compatibility) + case "$filename" in + CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) + filename="${filename}_safe" + ;; + esac + + # Limit length + if [[ ${#filename} -gt $max_length ]]; then + filename="${filename:0:$max_length}" + fi + + # Ensure it's not empty after sanitization + if [[ -z "$filename" ]]; then + filename="unnamed" + fi + + echo "$filename" +} + +# Validate Docker image name +validate_docker_image() { + local image="$1" + + if [[ -z "$image" ]]; then + error_exit "Docker image name cannot be empty" + fi + + # Basic Docker image validation + if [[ ! "$image" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*(:([a-zA-Z0-9._-]+))?$ ]]; then + error_exit "Invalid Docker image format: $image" + fi + + # Check for latest tag warning + if [[ "$image" =~ :latest$ || ! "$image" =~ : ]]; then + warning "Using 'latest' tag is not recommended: $image" + fi + + echo "$image" +} + +# Validate network name +validate_network_name() { + local name="$1" + + if [[ -z "$name" ]]; then + error_exit "Network name cannot be empty" + fi + + # Docker network name validation + if [[ ! "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then + error_exit "Invalid network name: $name" + fi + + # Check length + if [[ ${#name} -gt 63 ]]; then + error_exit "Network name too long: ${#name} characters (max 63)" + fi + + echo "$name" +} + +# Validate volume name +validate_volume_name() { + local name="$1" + + if [[ -z "$name" ]]; then + error_exit "Volume name cannot be empty" + fi + + # Docker volume name validation + if [[ ! "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then + error_exit "Invalid volume name: $name" + fi + + # Check length + if [[ ${#name} -gt 63 ]]; then + error_exit "Volume name too long: ${#name} characters (max 63)" + fi + + echo "$name" +} + +# Comprehensive input validation for user inputs +validate_user_input() { + local input="$1" + local input_type="$2" + local options="$3" + + case "$input_type" in + "path") + validate_directory_path "$input" "$options" + ;; + "timezone") + validate_timezone "$input" + ;; + "domain") + validate_domain "$input" + ;; + "email") + validate_email "$input" + ;; + "port") + validate_port "$input" "$options" + ;; + "ip") + validate_ip "$input" + ;; + "container_name") + validate_container_name "$input" + ;; + "uid") + validate_uid "$input" + ;; + "gid") + validate_gid "$input" + ;; + "memory") + validate_memory_size "$input" + ;; + "disk") + validate_disk_size "$input" + ;; + "url") + validate_url "$input" "$options" + ;; + "filename") + sanitize_filename "$input" "$options" + ;; + "docker_image") + validate_docker_image "$input" + ;; + "network_name") + validate_network_name "$input" + ;; + "volume_name") + validate_volume_name "$input" + ;; + *) + error_exit "Unknown input type: $input_type" + ;; + esac +} + +# Batch validation for multiple inputs +validate_inputs() { + local -A inputs + local -A types + local -A options + + # Parse arguments (input_name:input_value:type:options) + while [[ $# -gt 0 ]]; do + local arg="$1" + local input_name="${arg%%:*}" + local remaining="${arg#*:}" + local input_value="${remaining%%:*}" + remaining="${remaining#*:}" + local input_type="${remaining%%:*}" + local input_options="${remaining#*:}" + + inputs["$input_name"]="$input_value" + types["$input_name"]="$input_type" + options["$input_name"]="$input_options" + + shift + done + + # Validate all inputs + for input_name in "${!inputs[@]}"; do + local validated_value + validated_value=$(validate_user_input "${inputs[$input_name]}" "${types[$input_name]}" "${options[$input_name]}") + echo "${input_name}=${validated_value}" + done +} + +# Interactive input validation +read_and_validate() { + local prompt="$1" + local input_type="$2" + local options="$3" + local default="$4" + local allow_empty="${5:-false}" + + while true; do + local input + if [[ -n "$default" ]]; then + read -r -p "$prompt [$default]: " input + input="${input:-$default}" + else + read -r -p "$prompt: " input + fi + + if [[ -z "$input" && "$allow_empty" == "true" ]]; then + echo "" + return 0 + fi + + if [[ -z "$input" ]]; then + warning "Input cannot be empty" + continue + fi + + if validate_user_input "$input" "$input_type" "$options" >/dev/null 2>&1; then + echo "$input" + return 0 + else + warning "Invalid input. Please try again." + fi + done +} \ No newline at end of file