Release HOPS v3.1.0 with major security and architecture improvements
🆕 New Features: - Encrypted secret management with AES-256 encryption - Privilege separation (root vs user operations) - Comprehensive input validation and sanitization - Pinned container versions for security - Modular architecture with shared libraries 🔒 Security Enhancements: - Encrypted .env file storage with master key management - Input validation preventing injection attacks - Secure password generation with complexity requirements - Enhanced file permissions and ownership handling - Security auditing capabilities 🏗️ Architecture Improvements: - Shared library structure (lib/) for common functions - Enhanced error handling with detailed context - Improved service definitions with validation - Standardized logging and UI components - Better code organization and maintainability 📝 New Components: - hops_install.sh: New secure installation wrapper - hops_privileged_setup.sh: Root-only operations - hops_user_operations.sh: User operations without sudo - hops_service_definitions_improved.sh: Enhanced service generation - lib/: Shared libraries for common functionality - CLAUDE.md: Complete development documentation 🔧 User Experience: - Multiple installation methods (new secure, manual, legacy) - Better error messages and troubleshooting guidance - Improved service management commands - Enhanced documentation and help system This release maintains backward compatibility while adding enterprise-grade security features. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||
@@ -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 <services> # 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 <service> # 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
|
||||
|
||||
Executable
+90
@@ -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"
|
||||
Executable
+237
@@ -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
|
||||
Executable
+572
@@ -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 <<EOF
|
||||
- PUID=\${PUID:-1000}
|
||||
- PGID=\${PGID:-1000}
|
||||
- TZ=\${TZ:-UTC}
|
||||
- UMASK=002
|
||||
EOF
|
||||
}
|
||||
|
||||
# Common restart policy
|
||||
get_restart_policy() {
|
||||
echo " restart: unless-stopped"
|
||||
}
|
||||
|
||||
# Common network configuration
|
||||
get_homelab_network() {
|
||||
cat <<EOF
|
||||
networks:
|
||||
- homelab
|
||||
EOF
|
||||
}
|
||||
|
||||
# Common healthcheck for web services
|
||||
get_web_healthcheck() {
|
||||
local service_name="$1"
|
||||
local path="${2:-/}"
|
||||
|
||||
if [[ -z "$service_name" ]]; then
|
||||
error_exit "Service name required for healthcheck"
|
||||
fi
|
||||
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
cat <<EOF
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:$port$path || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
EOF
|
||||
}
|
||||
|
||||
# Common volume configuration
|
||||
get_common_volumes() {
|
||||
local service_name="$1"
|
||||
|
||||
if [[ -z "$service_name" ]]; then
|
||||
error_exit "Service name required for volumes"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
volumes:
|
||||
- \${CONFIG_ROOT:-/opt/appdata}/$service_name:/config
|
||||
- \${DATA_ROOT:-/mnt/media}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
EOF
|
||||
}
|
||||
|
||||
# Common Traefik labels
|
||||
get_traefik_labels() {
|
||||
local service_name="$1"
|
||||
|
||||
if [[ -z "$service_name" ]]; then
|
||||
error_exit "Service name required for Traefik labels"
|
||||
fi
|
||||
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
cat <<EOF
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.$service_name.rule=Host(\`$service_name.\${DOMAIN:-localhost}\`)"
|
||||
- "traefik.http.routers.$service_name.entrypoints=websecure"
|
||||
- "traefik.http.routers.$service_name.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.$service_name.loadbalancer.server.port=$port"
|
||||
EOF
|
||||
}
|
||||
|
||||
# --------------------------------------------
|
||||
# SERVICE GENERATORS
|
||||
# --------------------------------------------
|
||||
|
||||
# Generate *arr service (template for Sonarr, Radarr, etc.)
|
||||
generate_arr_service() {
|
||||
local service_name="$1"
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
local image=$(get_service_image "$service_name")
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
cat <<EOF
|
||||
$service_name:
|
||||
image: $image
|
||||
container_name: $service_name
|
||||
$(get_restart_policy)
|
||||
ports:
|
||||
- "$port:$port"
|
||||
environment:
|
||||
$(get_linuxserver_env)
|
||||
$(get_common_volumes "$service_name")
|
||||
$(get_web_healthcheck "$service_name")
|
||||
$(get_homelab_network)
|
||||
$(get_traefik_labels "$service_name")
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate media server service
|
||||
generate_media_server() {
|
||||
local service_name="$1"
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
local image=$(get_service_image "$service_name")
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
# Special handling for Plex
|
||||
local additional_config=""
|
||||
if [[ "$service_name" == "plex" ]]; then
|
||||
additional_config=" - PLEX_CLAIM=\${PLEX_CLAIM:-}
|
||||
- ADVERTISE_IP=\${ADVERTISE_IP:-}"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
$service_name:
|
||||
image: $image
|
||||
container_name: $service_name
|
||||
$(get_restart_policy)
|
||||
ports:
|
||||
- "$port:$port"
|
||||
environment:
|
||||
$(get_linuxserver_env)
|
||||
$additional_config
|
||||
$(get_common_volumes "$service_name")
|
||||
$(get_web_healthcheck "$service_name")
|
||||
$(get_homelab_network)
|
||||
$(get_traefik_labels "$service_name")
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate download client service
|
||||
generate_download_client() {
|
||||
local service_name="$1"
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
local image=$(get_service_image "$service_name")
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
# Special handling for qBittorrent
|
||||
local additional_config=""
|
||||
if [[ "$service_name" == "qbittorrent" ]]; then
|
||||
additional_config=" - WEBUI_PORT=$port"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
$service_name:
|
||||
image: $image
|
||||
container_name: $service_name
|
||||
$(get_restart_policy)
|
||||
ports:
|
||||
- "$port:$port"
|
||||
environment:
|
||||
$(get_linuxserver_env)
|
||||
$additional_config
|
||||
$(get_common_volumes "$service_name")
|
||||
$(get_web_healthcheck "$service_name")
|
||||
$(get_homelab_network)
|
||||
$(get_traefik_labels "$service_name")
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate monitoring service
|
||||
generate_monitoring_service() {
|
||||
local service_name="$1"
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
local image=$(get_service_image "$service_name")
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
# Special handling for Portainer
|
||||
local additional_config=""
|
||||
local volume_config=""
|
||||
|
||||
if [[ "$service_name" == "portainer" ]]; then
|
||||
volume_config=" volumes:
|
||||
- \${CONFIG_ROOT:-/opt/appdata}/$service_name:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /etc/localtime:/etc/localtime:ro"
|
||||
elif [[ "$service_name" == "watchtower" ]]; then
|
||||
volume_config=" volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /etc/localtime:/etc/localtime:ro"
|
||||
additional_config=" - WATCHTOWER_CLEANUP=true
|
||||
- WATCHTOWER_SCHEDULE=0 0 4 * * *"
|
||||
else
|
||||
volume_config=$(get_common_volumes "$service_name")
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
$service_name:
|
||||
image: $image
|
||||
container_name: $service_name
|
||||
$(get_restart_policy)
|
||||
ports:
|
||||
- "$port:$port"
|
||||
environment:
|
||||
$(get_linuxserver_env)
|
||||
$additional_config
|
||||
$volume_config
|
||||
$(get_web_healthcheck "$service_name")
|
||||
$(get_homelab_network)
|
||||
$(get_traefik_labels "$service_name")
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate Traefik service
|
||||
generate_traefik() {
|
||||
local service_name="traefik"
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
local image=$(get_service_image "$service_name")
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
cat <<EOF
|
||||
traefik:
|
||||
image: $image
|
||||
container_name: traefik
|
||||
$(get_restart_policy)
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "$port:$port"
|
||||
environment:
|
||||
- CF_API_EMAIL=\${CF_API_EMAIL:-}
|
||||
- CF_API_KEY=\${CF_API_KEY:-}
|
||||
command:
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=\${ACME_EMAIL:-admin@localhost}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
volumes:
|
||||
- \${CONFIG_ROOT:-/opt/appdata}/traefik:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
- homelab
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.traefik.rule=Host(\`traefik.\${DOMAIN:-localhost}\`)"
|
||||
- "traefik.http.routers.traefik.entrypoints=websecure"
|
||||
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.traefik.loadbalancer.server.port=$port"
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Main service generator function
|
||||
generate_service_definition() {
|
||||
local service_name="$1"
|
||||
|
||||
if [[ -z "$service_name" ]]; then
|
||||
error_exit "Service name is required"
|
||||
fi
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
debug "Generating service definition for: $service_name"
|
||||
|
||||
case "$service_name" in
|
||||
# Media Management (*arr Stack)
|
||||
"sonarr"|"radarr"|"lidarr"|"readarr"|"bazarr"|"prowlarr")
|
||||
generate_arr_service "$service_name"
|
||||
;;
|
||||
|
||||
# Download Clients
|
||||
"qbittorrent"|"transmission"|"nzbget"|"sabnzbd")
|
||||
generate_download_client "$service_name"
|
||||
;;
|
||||
|
||||
# Media Servers
|
||||
"jellyfin"|"plex"|"emby"|"jellystat")
|
||||
generate_media_server "$service_name"
|
||||
;;
|
||||
|
||||
# Request Management
|
||||
"overseerr"|"jellyseerr"|"ombi")
|
||||
generate_arr_service "$service_name" # They use similar patterns
|
||||
;;
|
||||
|
||||
# Reverse Proxy & Security
|
||||
"traefik")
|
||||
generate_traefik
|
||||
;;
|
||||
|
||||
# Monitoring & Management
|
||||
"portainer"|"uptime-kuma"|"watchtower")
|
||||
generate_monitoring_service "$service_name"
|
||||
;;
|
||||
|
||||
# Other services
|
||||
"nginx-proxy-manager"|"authelia"|"tdarr")
|
||||
generate_arr_service "$service_name" # Use generic template
|
||||
;;
|
||||
|
||||
*)
|
||||
error_exit "Unknown service: $service_name"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Generate multiple services
|
||||
generate_multiple_services() {
|
||||
local services=("$@")
|
||||
|
||||
if [[ ${#services[@]} -eq 0 ]]; then
|
||||
error_exit "No services specified"
|
||||
fi
|
||||
|
||||
debug "Generating definitions for ${#services[@]} services"
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
generate_service_definition "$service"
|
||||
done
|
||||
}
|
||||
|
||||
# List all available services
|
||||
list_available_services() {
|
||||
echo "Available 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
|
||||
}
|
||||
|
||||
# Validate service configuration
|
||||
validate_service_config() {
|
||||
local service_name="$1"
|
||||
|
||||
validate_service_name_internal "$service_name"
|
||||
|
||||
local image=$(get_service_image "$service_name")
|
||||
local port=$(get_service_port "$service_name")
|
||||
|
||||
# Check if image exists (basic validation)
|
||||
if [[ -z "$image" ]]; then
|
||||
error_exit "No image defined for service: $service_name"
|
||||
fi
|
||||
|
||||
# Check if port is valid
|
||||
if [[ ! "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
|
||||
error_exit "Invalid port for service $service_name: $port"
|
||||
fi
|
||||
|
||||
success "Service configuration valid: $service_name"
|
||||
}
|
||||
|
||||
# Main function for command line usage
|
||||
main() {
|
||||
local action="$1"
|
||||
shift
|
||||
|
||||
case "$action" in
|
||||
"generate")
|
||||
if [[ $# -eq 0 ]]; then
|
||||
error_exit "Usage: $0 generate <service_name> [service_name...]"
|
||||
fi
|
||||
generate_multiple_services "$@"
|
||||
;;
|
||||
|
||||
"list")
|
||||
list_available_services
|
||||
;;
|
||||
|
||||
"validate")
|
||||
if [[ $# -eq 0 ]]; then
|
||||
error_exit "Usage: $0 validate <service_name>"
|
||||
fi
|
||||
validate_service_config "$1"
|
||||
;;
|
||||
|
||||
"help"|"--help"|"-h")
|
||||
cat <<EOF
|
||||
HOPS Service Definitions - Improved Version
|
||||
|
||||
Usage: $0 <action> [options]
|
||||
|
||||
Actions:
|
||||
generate <service>... Generate service definitions
|
||||
list List all available services
|
||||
validate <service> 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
|
||||
Executable
+175
@@ -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 <service1> [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 <service_name>"
|
||||
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
|
||||
+251
@@ -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"
|
||||
}
|
||||
+484
@@ -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"
|
||||
}
|
||||
Executable
+750
@@ -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 <service1> [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 <service_name>"
|
||||
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 <<EOF
|
||||
HOPS Privilege Management System
|
||||
|
||||
Usage: $0 <action> [options]
|
||||
|
||||
Actions:
|
||||
create-setup <file> Create privileged setup script
|
||||
create-user <file> Create non-privileged user script
|
||||
create-wrapper <file> Create installation wrapper
|
||||
create-all Create all scripts
|
||||
run <op> <cmd> [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
|
||||
Executable
+577
@@ -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 <env_file> [output_file]"
|
||||
fi
|
||||
encrypt_environment "$@"
|
||||
;;
|
||||
|
||||
"decrypt")
|
||||
decrypt_environment "$@"
|
||||
;;
|
||||
|
||||
"update")
|
||||
if [[ $# -lt 2 ]]; then
|
||||
error_exit "Usage: $0 update <key> <value> [file]"
|
||||
fi
|
||||
update_encrypted_environment "${3:-$ENCRYPTED_ENV_FILE}" "$1" "$2"
|
||||
;;
|
||||
|
||||
"get")
|
||||
if [[ $# -eq 0 ]]; then
|
||||
error_exit "Usage: $0 get <key> [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 <backup_file> [target_file]"
|
||||
fi
|
||||
restore_encrypted_environment "$@"
|
||||
;;
|
||||
|
||||
"change-key")
|
||||
change_master_key
|
||||
;;
|
||||
|
||||
"verify")
|
||||
verify_encrypted_environment "$@"
|
||||
;;
|
||||
|
||||
"help"|"--help"|"-h")
|
||||
cat <<EOF
|
||||
HOPS Secret Management System
|
||||
|
||||
Usage: $0 <action> [options]
|
||||
|
||||
Actions:
|
||||
init Initialize secrets management
|
||||
create Create new encrypted environment
|
||||
encrypt <env_file> Encrypt environment file
|
||||
decrypt [encrypted_file] Decrypt environment file
|
||||
update <key> <value> Update environment value
|
||||
get <key> Get environment value
|
||||
list List all environment keys
|
||||
backup Backup encrypted environment
|
||||
restore <backup_file> 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
|
||||
+450
@@ -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
|
||||
}
|
||||
+318
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user