diff --git a/README.md b/README.md index a820c51..e9fbfff 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. -Version: 0.9-rc24 (release candidate) +Version: 0.9-rc25 (release candidate) This is a release candidate. Expect rapid iteration and breaking changes until 1.0. @@ -156,6 +156,7 @@ php artisan test --compact ## Initial Release +- 0.9-rc25: Added Gitea installer script. - 0.9-rc24: WAF installer improvements and ModSecurity setup fixes. - 0.9-rc: initial release candidate with core hosting, mail, DNS, SSL, backups, and migrations. diff --git a/VERSION b/VERSION index 262cc3a..8bf2576 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc24 +VERSION=0.9-rc25 diff --git a/install_from_gitea.sh b/install_from_gitea.sh new file mode 100755 index 0000000..8e86f03 --- /dev/null +++ b/install_from_gitea.sh @@ -0,0 +1,3451 @@ +#!/bin/bash +# +# Jabali Panel Installer +# +# Quick install (if curl/sudo are available): +# curl -fsSL http://192.168.100.100:3001/shukivaknin/jabali-panel/raw/branch/main/install_from_gitea.sh | sudo bash +# +# Minimal installation (if curl/sudo not installed): +# apt-get update && apt-get install -y curl sudo +# curl -fsSL http://192.168.100.100:3001/shukivaknin/jabali-panel/raw/branch/main/install_from_gitea.sh | sudo bash +# +set -e + +# Version - will be read from VERSION file after clone, this is fallback +JABALI_VERSION="0.9-rc21" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Configuration +JABALI_DIR="/var/www/jabali" +JABALI_USER="www-data" +JABALI_REPO="http://192.168.100.100:3001/shukivaknin/jabali-panel.git" +NODE_VERSION="20" + +# PHP version will be detected after installation +PHP_VERSION="" + +# Feature flags (default: all enabled) +INSTALL_MAIL=true +INSTALL_DNS=true +INSTALL_FIREWALL=true +INSTALL_SECURITY=true # Fail2ban + ClamAV + +# Feature selection menu +select_features() { + echo "" + echo -e "${BOLD}Installation Options${NC}" + echo "" + echo "Jabali Panel includes optional components that can be installed based on your needs." + echo "Each component uses additional server resources." + echo "" + + # Check if non-interactive mode + if [[ -n "$JABALI_MINIMAL" ]]; then + info "Minimal installation mode: Only core components will be installed" + INSTALL_MAIL=false + INSTALL_DNS=false + INSTALL_SECURITY=false + return + fi + + if [[ -n "$JABALI_FULL" ]]; then + info "Full installation mode: All components will be installed" + return + fi + + echo "Select installation type:" + echo "" + echo " 1) Full Installation (Recommended)" + echo " - Web Server (Nginx, PHP, MariaDB, Redis)" + echo " - Mail Server (Postfix, Dovecot, Rspamd)" + echo " - DNS Server (BIND9)" + echo " - Security (Firewall, Fail2ban, ClamAV)" + echo "" + echo " 2) Minimal Installation" + echo " - Web Server only (Nginx, PHP, MariaDB, Redis)" + echo " - Firewall (UFW)" + echo "" + echo " 3) Custom Installation" + echo " - Choose individual components" + echo "" + + local choice + read -p "Enter choice [1-3]: " choice < /dev/tty + + case $choice in + 1) + info "Full installation selected" + ;; + 2) + info "Minimal installation selected" + INSTALL_MAIL=false + INSTALL_DNS=false + INSTALL_SECURITY=false + ;; + 3) + echo "" + echo -e "${BOLD}Custom Installation${NC}" + echo "" + + # Mail Server + read -p "Install Mail Server (Postfix, Dovecot)? [Y/n]: " mail_choice < /dev/tty + if [[ "$mail_choice" =~ ^[Nn]$ ]]; then + INSTALL_MAIL=false + fi + + # DNS Server + read -p "Install DNS Server (BIND9)? [Y/n]: " dns_choice < /dev/tty + if [[ "$dns_choice" =~ ^[Nn]$ ]]; then + INSTALL_DNS=false + fi + + # Firewall + read -p "Install Firewall (UFW)? [Y/n]: " fw_choice < /dev/tty + if [[ "$fw_choice" =~ ^[Nn]$ ]]; then + INSTALL_FIREWALL=false + fi + + # Security tools + read -p "Install Security Tools (Fail2ban, ClamAV)? [Y/n]: " sec_choice < /dev/tty + if [[ "$sec_choice" =~ ^[Nn]$ ]]; then + INSTALL_SECURITY=false + fi + + echo "" + info "Custom installation configured" + ;; + *) + info "Defaulting to full installation" + ;; + esac + + echo "" + echo -e "${BOLD}Components to install:${NC}" + echo -e " - Web Server: ${GREEN}Yes${NC}" + [[ "$INSTALL_MAIL" == "true" ]] && echo -e " - Mail Server: ${GREEN}Yes${NC}" || echo -e " - Mail Server: ${YELLOW}No${NC}" + [[ "$INSTALL_DNS" == "true" ]] && echo -e " - DNS Server: ${GREEN}Yes${NC}" || echo -e " - DNS Server: ${YELLOW}No${NC}" + [[ "$INSTALL_FIREWALL" == "true" ]] && echo -e " - Firewall: ${GREEN}Yes${NC}" || echo -e " - Firewall: ${YELLOW}No${NC}" + [[ "$INSTALL_SECURITY" == "true" ]] && echo -e " - Security Tools: ${GREEN}Yes${NC}" || echo -e " - Security Tools: ${YELLOW}No${NC}" + echo "" +} + +# Detect installed PHP version +detect_php_version() { + # Try to find the highest installed PHP version + if command -v php &> /dev/null; then + PHP_VERSION=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null) + fi + + # Fallback: check for PHP-FPM sockets + if [[ -z "$PHP_VERSION" ]]; then + for ver in 8.4 8.3 8.2 8.1 8.0; do + if [[ -f "/etc/php/${ver}/fpm/php.ini" ]]; then + PHP_VERSION="$ver" + break + fi + done + fi + + if [[ -z "$PHP_VERSION" ]]; then + error "Could not detect PHP version. Please ensure PHP is installed." + fi + + info "Detected PHP version: $PHP_VERSION" +} + +# Logging +log() { + echo -e "${GREEN}[✓]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[!]${NC} $1" +} + +error() { + echo -e "${RED}[✗]${NC} $1" + exit 1 +} + +info() { + echo -e "${CYAN}[i]${NC} $1" +} + +header() { + echo "" + echo -e "${BOLD}${BLUE}=== $1 ===${NC}" + echo "" +} + +# Check if running as root +check_root() { + if [[ $EUID -ne 0 ]]; then + error "This script must be run as root (use sudo)" + fi +} + +# Check OS +check_os() { + if [[ ! -f /etc/debian_version ]]; then + error "This installer only supports Debian/Ubuntu systems" + fi + + . /etc/os-release + info "Detected: $PRETTY_NAME" + + case $ID in + debian) + # Read debian_version file + local debian_ver=$(cat /etc/debian_version 2>/dev/null || echo "") + + # Check for testing/unstable first (trixie/sid) + if [[ "$debian_ver" == *trixie* ]] || [[ "$debian_ver" == *sid* ]] || \ + [[ "$VERSION_CODENAME" == "trixie" ]] || [[ "$VERSION_CODENAME" == "sid" ]] || \ + [[ -z "${VERSION_ID:-}" ]]; then + info "Detected Debian testing/unstable (trixie/sid) - proceeding" + elif [[ "$debian_ver" == *bookworm* ]] || [[ "$VERSION_CODENAME" == "bookworm" ]]; then + info "Detected Debian 12 (bookworm)" + elif [[ "$debian_ver" == *bullseye* ]] || [[ "$VERSION_CODENAME" == "bullseye" ]]; then + info "Detected Debian 11 (bullseye)" + elif [[ -n "${VERSION_ID:-}" ]] && [[ "${VERSION_ID}" -lt 11 ]]; then + error "Debian 11 or later is required" + else + # Try numeric extraction from debian_version + local num_ver=$(echo "$debian_ver" | grep -oE '^[0-9]+' | head -1) + if [[ -n "$num_ver" ]] && [[ "$num_ver" -ge 11 ]]; then + info "Detected Debian $num_ver" + else + warn "Unknown Debian version: $debian_ver - proceeding anyway" + fi + fi + ;; + ubuntu) + if [[ -n "${VERSION_ID:-}" ]] && [[ ${VERSION_ID%.*} -lt 22 ]]; then + error "Ubuntu 22.04 or later is required" + fi + ;; + *) + warn "Untested distribution: $ID. Proceeding anyway..." + ;; + esac +} + +# Display banner +show_banner() { + echo "" + echo -e "${YELLOW}░░░░░██╗░█████╗░██████╗░░█████╗░██╗░░░░░██╗${NC}" + echo -e "${YELLOW}░░░░░██║██╔══██╗██╔══██╗██╔══██╗██║░░░░░██║${NC}" + echo -e "${YELLOW}░░░░░██║███████║██████╦╝███████║██║░░░░░██║${NC}" + echo -e "${YELLOW}██╗░░██║██╔══██║██╔══██╗██╔══██║██║░░░░░██║${NC}" + echo -e "${YELLOW}╚█████╔╝██║░░██║██████╦╝██║░░██║███████╗██║${NC}" + echo -e "${YELLOW}░╚════╝░╚═╝░░╚═╝╚═════╝░╚═╝░░╚═╝╚══════╝╚═╝${NC}" + echo "" + echo -e " ${BOLD}Jabali Panel${NC} v${JABALI_VERSION} - ${CYAN}Modern Web Hosting Control Panel${NC}" + echo "" +} + +# Prompt for server hostname +prompt_hostname() { + local current_hostname=$(hostname -f 2>/dev/null || hostname) + local server_ip=$(hostname -I | awk '{print $1}') + + # Check if SERVER_HOSTNAME is already set (non-interactive mode) + if [[ -n "$SERVER_HOSTNAME" ]]; then + # Validate the preset hostname + if [[ ! "$SERVER_HOSTNAME" =~ ^[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 "Invalid hostname format: $SERVER_HOSTNAME. Please use a valid FQDN (e.g., panel.example.com)" + fi + info "Using preset hostname: $SERVER_HOSTNAME" + else + echo -e "${BOLD}Server Configuration${NC}" + echo "" + echo "Enter the fully qualified domain name (FQDN) for this server." + echo "This will be used for:" + echo " - Server hostname" + echo " - Admin email address (admin@hostname)" + echo " - Mail server configuration" + echo "" + echo -e "Current hostname: ${CYAN}${current_hostname}${NC}" + echo -e "Server IP: ${CYAN}${server_ip}${NC}" + echo "" + + while true; do + read -p "Enter hostname [$current_hostname]: " SERVER_HOSTNAME < /dev/tty + + # Use current hostname as default if empty + if [[ -z "$SERVER_HOSTNAME" ]]; then + SERVER_HOSTNAME="$current_hostname" + fi + + # Basic hostname validation + if [[ ! "$SERVER_HOSTNAME" =~ ^[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 + warn "Invalid hostname format. Please use a valid FQDN (e.g., panel.example.com)" + continue + fi + + break + done + + echo "" + info "Using hostname: $SERVER_HOSTNAME" + echo "" + fi + + # Set system hostname + hostnamectl set-hostname "$SERVER_HOSTNAME" 2>/dev/null || hostname "$SERVER_HOSTNAME" + + # Update /etc/hosts + if ! grep -q "$SERVER_HOSTNAME" /etc/hosts; then + echo "127.0.1.1 $SERVER_HOSTNAME" >> /etc/hosts + fi + + # Export for use in other functions + export SERVER_HOSTNAME + export ADMIN_EMAIL="admin@${SERVER_HOSTNAME}" +} + +# Add required repositories +add_repositories() { + header "Adding Repositories" + + # Update package list + apt-get update -qq + + # Install prerequisites (software-properties-common is optional, mainly for Ubuntu) + apt-get install -y -qq apt-transport-https ca-certificates curl gnupg lsb-release sudo + apt-get install -y -qq software-properties-common 2>/dev/null || true + + # Detect codename + local codename=$(lsb_release -sc) + + # Ensure Debian contrib repository for geoipupdate and related packages + if [[ -f /etc/debian_version ]]; then + info "Ensuring Debian contrib repository..." + local contrib_list="/etc/apt/sources.list.d/jabali-contrib.list" + if [[ ! -f "$contrib_list" ]]; then + cat > "$contrib_list" </dev/null || true + echo "deb [signed-by=/usr/share/keyrings/sury-php.gpg] https://packages.sury.org/php/ ${codename} main" > /etc/apt/sources.list.d/php.list + + # Add NodeJS repository + if [[ ! -f /etc/apt/sources.list.d/nodesource.list ]]; then + info "Adding NodeJS repository..." + curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - > /dev/null 2>&1 + fi + + # Add MariaDB repository (optional, system version usually fine) + + apt-get update -qq + log "Repositories configured" +} + +# Install system packages +install_packages() { + header "Installing System Packages" + + # Clean up conflicting packages from previous failed installations + if dpkg -l apache2 2>/dev/null | grep -q '^ii'; then + info "Removing Apache2 (conflicts with nginx)..." + systemctl stop apache2 2>/dev/null || true + systemctl disable apache2 2>/dev/null || true + DEBIAN_FRONTEND=noninteractive apt-get purge -y apache2 apache2-bin apache2-utils apache2-data 2>/dev/null || true + fi + + # Clean up broken PHP state from previous failed installations + # Check if php8.4 packages exist in any state (installed, config-files, half-installed, etc.) + # dpkg remembers "deleted" config files and won't recreate them on reinstall + if dpkg -l 'php8.4*' 2>/dev/null | grep -qE '^(ii|rc|iU|iF|hi|pi)'; then + # Check if configs are missing or broken + if [[ ! -f /etc/php/8.4/fpm/php.ini ]] || [[ ! -f /etc/php/8.4/cli/php.ini ]] || \ + dpkg -l php8.4-fpm 2>/dev/null | grep -qE '^(rc|iU|iF)'; then + info "Cleaning up broken PHP installation..." + systemctl stop php8.4-fpm 2>/dev/null || true + systemctl reset-failed php8.4-fpm 2>/dev/null || true + DEBIAN_FRONTEND=noninteractive apt-get purge -y 'php8.4*' 2>/dev/null || true + rm -rf /etc/php/8.4 + apt-get clean + dpkg --configure -a 2>/dev/null || true + fi + fi + + # Core packages (always installed) + local base_packages=( + # Web Server + nginx + + # Database + mariadb-server + mariadb-client + + # Cache + redis-server + + # SSL + certbot + python3-certbot-nginx + + # Utilities + git + curl + wget + zip + unzip + htop + net-tools + dnsutils + nodejs + acl + socat + sshpass + pigz + locales + + # Security (always installed) + fail2ban + geoipupdate + libnginx-mod-http-geoip2 + + # For screenshots (Puppeteer) + chromium + + # For PHP compilation/extensions + build-essential + + # Disk quota management + quota + + # Log analysis + goaccess + ) + + # Add Mail Server packages if enabled + if [[ "$INSTALL_MAIL" == "true" ]]; then + info "Including Mail Server packages..." + base_packages+=( + postfix + postfix-mysql + dovecot-core + dovecot-imapd + dovecot-pop3d + dovecot-lmtpd + dovecot-mysql + dovecot-sqlite + opendkim + opendkim-tools + rspamd + # Webmail + roundcube + roundcube-core + roundcube-sqlite3 + roundcube-plugins + # SQLite tools + sqlite3 + ) + fi + + # Add DNS Server packages if enabled + if [[ "$INSTALL_DNS" == "true" ]]; then + info "Including DNS Server packages..." + base_packages+=( + bind9 + bind9-utils + ) + fi + + # Add Firewall packages if enabled + if [[ "$INSTALL_FIREWALL" == "true" ]]; then + info "Including Firewall packages..." + base_packages+=( + ufw + ) + fi + + # Add Security packages if enabled + if [[ "$INSTALL_SECURITY" == "true" ]]; then + info "Including Security packages..." + base_packages+=( + clamav + clamav-daemon + clamav-freshclam + # Vulnerability scanners + lynis + # nikto is installed from GitHub (not in apt repos) + # Ruby for WPScan + ruby + ruby-dev + ) + fi + + # Prevent Apache2 and libapache2-mod-php from being installed + # (roundcube recommends apache2, php metapackage recommends libapache2-mod-php, but we use nginx+php-fpm) + info "Blocking Apache2 and mod-php installation (we use nginx + php-fpm)..." + apt-mark hold apache2 libapache2-mod-php libapache2-mod-php8.4 2>/dev/null || true + + # Pre-configure postfix and roundcube to avoid interactive prompts + # Note: debconf templates may not exist yet on fresh install, so suppress errors + if [[ "$INSTALL_MAIL" == "true" ]]; then + echo "postfix postfix/mailname string $(hostname -f)" | debconf-set-selections 2>/dev/null || true + echo "postfix postfix/main_mailer_type string 'Internet Site'" | debconf-set-selections 2>/dev/null || true + # Skip roundcube dbconfig - we configure it manually + echo "roundcube-core roundcube/dbconfig-install boolean false" | debconf-set-selections 2>/dev/null || true + echo "roundcube-core roundcube/database-type select sqlite3" | debconf-set-selections 2>/dev/null || true + fi + + info "Installing base packages..." + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${base_packages[@]}" || { + warn "Some packages may not be available, retrying individually..." + for pkg in "${base_packages[@]}"; do + apt-get install -y -qq "$pkg" 2>/dev/null || warn "Could not install: $pkg" + done + } + + if command -v locale-gen >/dev/null 2>&1; then + info "Configuring locales..." + if ! grep -q '^en_US.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + fi + locale-gen >/dev/null 2>&1 || warn "Locale generation failed" + update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || warn "Failed to set default locale" + fi + + # Unhold packages in case user wants to install them manually later + apt-mark unhold apache2 libapache2-mod-php libapache2-mod-php8.4 2>/dev/null || true + + # Install PHP 8.4 (required for Jabali Panel) + info "Installing PHP 8.4..." + + # Stop, disable and mask Apache2 if installed (conflicts with nginx on port 80) + # Apache2 can be installed as a dependency of some PHP packages + if dpkg -l apache2 2>/dev/null | grep -q '^ii' || systemctl is-active --quiet apache2 2>/dev/null; then + info "Stopping Apache2 (conflicts with nginx)..." + systemctl stop apache2 2>/dev/null || true + systemctl disable apache2 2>/dev/null || true + systemctl mask apache2 2>/dev/null || true + fi + + # Clean up any broken PHP-FPM state from previous installations + if systemctl is-failed --quiet php8.4-fpm 2>/dev/null; then + info "Resetting failed PHP-FPM service state..." + systemctl reset-failed php8.4-fpm + fi + + # Required PHP extensions for Jabali Panel + local php_extensions=( + php8.4 + php8.4-fpm + php8.4-cli + php8.4-common + php8.4-mysql + php8.4-pgsql + php8.4-sqlite3 + php8.4-curl + php8.4-gd + php8.4-mbstring + php8.4-xml # Provides dom extension + php8.4-zip + php8.4-bcmath + php8.4-intl # Required by Filament + php8.4-readline + php8.4-soap + php8.4-imap + php8.4-ldap + php8.4-imagick + php8.4-redis + php8.4-opcache + ) + + # Install all PHP packages (use --force-confmiss to handle dpkg's "deleted config" state) + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confmiss" "${php_extensions[@]}"; then + warn "PHP installation had errors, attempting aggressive recovery..." + + # Stop PHP-FPM if it's somehow running in a broken state + systemctl stop php8.4-fpm 2>/dev/null || true + systemctl reset-failed php8.4-fpm 2>/dev/null || true + + # Purge ALL PHP 8.4 packages including config files + info "Purging all PHP 8.4 packages..." + DEBIAN_FRONTEND=noninteractive apt-get purge -y 'php8.4*' 2>/dev/null || true + + # Also remove libapache2-mod-php if it got installed (it conflicts with php-fpm) + DEBIAN_FRONTEND=noninteractive apt-get purge -y 'libapache2-mod-php*' 2>/dev/null || true + + # Remove config directories to force fresh install (dpkg won't replace "deleted" configs) + info "Removing PHP config directories..." + rm -rf /etc/php/8.4/fpm + rm -rf /etc/php/8.4/cli + rm -rf /etc/php/8.4/apache2 + + # Clean package cache + apt-get clean + apt-get autoclean + + # Fix any broken dpkg state + dpkg --configure -a 2>/dev/null || true + + # Reinstall PHP with force-confmiss to ensure config files are created + info "Reinstalling PHP 8.4 with fresh configuration..." + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confmiss" "${php_extensions[@]}"; then + error "Failed to install PHP 8.4. Please check your system's package state and try again." + fi + fi + + # Stop and disable Apache2 completely - it conflicts with nginx on port 80 + # Apache2 can be installed as a dependency of PHP packages + if dpkg -l apache2 2>/dev/null | grep -q '^ii'; then + info "Disabling Apache2 (conflicts with nginx)..." + systemctl stop apache2 2>/dev/null || true + systemctl disable apache2 2>/dev/null || true + systemctl mask apache2 2>/dev/null || true # Prevent it from starting + fi + + # Verify PHP 8.4 is installed and working + if ! php -v 2>/dev/null | grep -q "PHP 8.4"; then + error "PHP 8.4 installation failed. Found: $(php -v 2>/dev/null | head -1)" + fi + + PHP_VERSION="8.4" + log "PHP 8.4 installed successfully" + + # Ensure PHP-FPM is properly configured + if [[ ! -f "/etc/php/8.4/fpm/php-fpm.conf" ]] || [[ ! -f "/etc/php/8.4/fpm/php.ini" ]]; then + warn "PHP-FPM config files missing after install" + info "Purging and reinstalling PHP-FPM with fresh config..." + systemctl stop php8.4-fpm 2>/dev/null || true + systemctl reset-failed php8.4-fpm 2>/dev/null || true + DEBIAN_FRONTEND=noninteractive apt-get purge -y php8.4-fpm 2>/dev/null || true + rm -rf /etc/php/8.4/fpm + apt-get clean + DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confmiss" php8.4-fpm + fi + + # Verify PHP-FPM is running + if ! systemctl is-active --quiet php8.4-fpm; then + # Reset failed state first if needed + systemctl reset-failed php8.4-fpm 2>/dev/null || true + if ! systemctl start php8.4-fpm; then + warn "PHP-FPM failed to start, attempting recovery..." + # Check for config errors + php-fpm8.4 -t 2>&1 || true + systemctl status php8.4-fpm --no-pager -l || true + fi + fi + + # Verify PHP CLI is working and has required extensions + info "Verifying PHP CLI and extensions..." + + if ! command -v php &>/dev/null; then + error "PHP CLI is not in PATH after installation." + fi + + # Ensure php.ini exists for CLI (dpkg doesn't replace deleted config files) + local cli_ini="/etc/php/8.4/cli/php.ini" + + if [[ ! -f "$cli_ini" ]]; then + warn "PHP CLI config file missing: $cli_ini" + info "Reinstalling php8.4-cli with fresh config..." + DEBIAN_FRONTEND=noninteractive apt-get purge -y php8.4-cli 2>/dev/null || true + rm -rf /etc/php/8.4/cli + DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confmiss" php8.4-cli + fi + + # Verify required extensions are available + local missing_ext="" + php -r "class_exists('Phar') || exit(1);" 2>/dev/null || missing_ext="$missing_ext phar" + php -r "extension_loaded('dom') || exit(1);" 2>/dev/null || missing_ext="$missing_ext dom" + php -r "extension_loaded('intl') || exit(1);" 2>/dev/null || missing_ext="$missing_ext intl" + php -r "extension_loaded('mbstring') || exit(1);" 2>/dev/null || missing_ext="$missing_ext mbstring" + + if [[ -n "$missing_ext" ]]; then + warn "Missing PHP extensions:$missing_ext" + info "Extension .ini files may be missing. Purging and reinstalling PHP packages..." + + # Purge php-common to remove stale config state, then reinstall all packages + DEBIAN_FRONTEND=noninteractive apt-get purge -y php8.4-common + DEBIAN_FRONTEND=noninteractive apt-get install -y "${php_extensions[@]}" + + # Check again + php -r "class_exists('Phar') || exit(1);" 2>/dev/null || error "PHP Phar extension is missing" + php -r "extension_loaded('dom') || exit(1);" 2>/dev/null || error "PHP DOM extension is missing (install php8.4-xml)" + php -r "extension_loaded('intl') || exit(1);" 2>/dev/null || error "PHP Intl extension is missing (install php8.4-intl)" + fi + + log "PHP 8.4 CLI verified with all required extensions" + + # Install WPScan if security is enabled + if [[ "$INSTALL_SECURITY" == "true" ]]; then + info "Installing WPScan..." + if command -v gem &> /dev/null; then + gem install wpscan --no-document 2>/dev/null && { + log "WPScan installed successfully" + } || { + warn "WPScan installation failed (may require more memory)" + } + else + warn "Ruby gem not available, skipping WPScan" + fi + + # Install Nikto from GitHub if not available via apt + if ! command -v nikto &> /dev/null; then + info "Installing Nikto from GitHub..." + if [[ ! -d "/opt/nikto" ]]; then + git clone https://github.com/sullo/nikto.git /opt/nikto 2>/dev/null && { + ln -sf /opt/nikto/program/nikto.pl /usr/local/bin/nikto + chmod +x /opt/nikto/program/nikto.pl + log "Nikto installed successfully" + } || { + warn "Nikto installation failed" + } + fi + fi + fi + + # Final Apache2 cleanup - ensure it's stopped and masked before nginx starts + if systemctl is-active --quiet apache2 2>/dev/null; then + warn "Apache2 is still running, forcing stop..." + systemctl stop apache2 || true + systemctl disable apache2 || true + systemctl mask apache2 || true + fi + + log "System packages installed" +} + +install_geoipupdate_binary() { + if command -v geoipupdate &>/dev/null; then + return + fi + + info "geoipupdate not found, installing from MaxMind releases..." + + local arch + arch="$(uname -m)" + local arch_token="$arch" + if [[ "$arch" == "x86_64" ]]; then + arch_token="amd64" + elif [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then + arch_token="arm64" + fi + + local api_url="https://api.github.com/repos/maxmind/geoipupdate/releases/latest" + local metadata + metadata=$(curl -fsSL "$api_url" 2>/dev/null || true) + if [[ -z "$metadata" ]]; then + metadata=$(wget -qO- "$api_url" 2>/dev/null || true) + fi + + if [[ -z "$metadata" ]]; then + warn "Failed to download geoipupdate release metadata" + return + fi + + local download_url + download_url=$(echo "$metadata" | grep -Eo "https://[^\"]+${arch_token}[^\"]+\\.tar\\.gz" | head -n1) + if [[ -z "$download_url" && "$arch_token" == "amd64" ]]; then + download_url=$(echo "$metadata" | grep -Eo "https://[^\"]+x86_64[^\"]+\\.tar\\.gz" | head -n1) + fi + + if [[ -z "$download_url" ]]; then + warn "No suitable geoipupdate binary found for ${arch}" + return + fi + + local tmp_dir + tmp_dir=$(mktemp -d) + local archive="${tmp_dir}/geoipupdate.tgz" + + if command -v curl &>/dev/null; then + curl -fsSL "$download_url" -o "$archive" 2>/dev/null || true + else + wget -qO "$archive" "$download_url" 2>/dev/null || true + fi + + if [[ ! -s "$archive" ]]; then + warn "Failed to download geoipupdate binary" + rm -rf "$tmp_dir" + return + fi + + tar -xzf "$archive" -C "$tmp_dir" 2>/dev/null || true + local binary + binary=$(find "$tmp_dir" -type f -name geoipupdate | head -n1) + if [[ -z "$binary" ]]; then + warn "geoipupdate binary not found in archive" + rm -rf "$tmp_dir" + return + fi + + install -m 0755 "$binary" /usr/local/bin/geoipupdate 2>/dev/null || true + rm -rf "$tmp_dir" +} + +# Install Composer +install_composer() { + header "Installing Composer" + + # PHP and Phar extension should already be verified by install_packages + # Just do a quick sanity check + if ! command -v php &>/dev/null; then + error "PHP is not installed or not in PATH" + fi + + # Quick Phar check (should already be verified, but be safe) + if ! php -r "echo class_exists('Phar') ? 'ok' : 'no';" 2>/dev/null | grep -q ok; then + error "PHP Phar extension is required. Please reinstall PHP CLI: apt-get install --reinstall php${PHP_VERSION}-cli" + fi + + info "PHP Phar extension: OK" + + if command -v composer &>/dev/null; then + log "Composer already installed" + return + fi + + info "Downloading and installing Composer..." + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + + if ! command -v composer &>/dev/null; then + error "Composer installation failed" + fi + + log "Composer installed" +} + +# Clone Jabali Panel +clone_jabali() { + header "Installing Jabali Panel" + + if [[ -d "$JABALI_DIR" ]]; then + warn "Jabali directory exists, backing up..." + mv "$JABALI_DIR" "${JABALI_DIR}.bak.$(date +%s)" + fi + + git clone "$JABALI_REPO" "$JABALI_DIR" + chown -R $JABALI_USER:$JABALI_USER "$JABALI_DIR" + + # Prevent git safe.directory issues for upgrades run as root or www-data + git config --system --add safe.directory "$JABALI_DIR" 2>/dev/null || true + sudo -u $JABALI_USER git config --global --add safe.directory "$JABALI_DIR" 2>/dev/null || true + + # Ensure runtime directories stay writable for PHP-FPM (default: www-data) + if id www-data &>/dev/null; then + chown -R $JABALI_USER:www-data \ + "$JABALI_DIR/database" \ + "$JABALI_DIR/storage" \ + "$JABALI_DIR/bootstrap/cache" 2>/dev/null || true + chmod -R g+rwX \ + "$JABALI_DIR/database" \ + "$JABALI_DIR/storage" \ + "$JABALI_DIR/bootstrap/cache" 2>/dev/null || true + find "$JABALI_DIR/database" "$JABALI_DIR/storage" "$JABALI_DIR/bootstrap/cache" -type d -exec chmod g+s {} + 2>/dev/null || true + fi + + # Read version from cloned VERSION file + if [[ -f "$JABALI_DIR/VERSION" ]]; then + source "$JABALI_DIR/VERSION" + JABALI_VERSION="${VERSION:-$JABALI_VERSION}" + info "Installed version: ${JABALI_VERSION}" + fi + + log "Jabali Panel cloned" +} + +# Configure PHP +configure_php() { + header "Configuring PHP" + + # Detect PHP version if not already set + if [[ -z "$PHP_VERSION" ]]; then + detect_php_version + fi + + # Start PHP-FPM first to ensure files are created + systemctl start php${PHP_VERSION}-fpm 2>/dev/null || true + + # Find PHP ini file - check multiple locations + local php_ini="" + local possible_paths=( + "/etc/php/${PHP_VERSION}/fpm/php.ini" + "/etc/php/${PHP_VERSION}/cli/php.ini" + "/etc/php/${PHP_VERSION}/php.ini" + "/etc/php/php.ini" + ) + + for path in "${possible_paths[@]}"; do + if [[ -f "$path" ]]; then + php_ini="$path" + break + fi + done + + # If still not found, try to find it with broader search + if [[ -z "$php_ini" ]]; then + php_ini=$(find /etc/php -name "php.ini" 2>/dev/null | head -1) + fi + + if [[ -z "$php_ini" || ! -f "$php_ini" ]]; then + warn "PHP configuration not found, skipping PHP configuration" + warn "You may need to configure PHP manually" + return + fi + + info "Configuring PHP ${PHP_VERSION} using $php_ini..." + + # PHP.ini settings for both FPM and CLI + local ini_files=("$php_ini") + [[ -f "/etc/php/${PHP_VERSION}/cli/php.ini" ]] && ini_files+=("/etc/php/${PHP_VERSION}/cli/php.ini") + [[ -f "/etc/php/${PHP_VERSION}/fpm/php.ini" ]] && ini_files+=("/etc/php/${PHP_VERSION}/fpm/php.ini") + + # Remove duplicates + ini_files=($(echo "${ini_files[@]}" | tr ' ' '\n' | sort -u | tr '\n' ' ')) + + for ini in "${ini_files[@]}"; do + if [[ -f "$ini" ]]; then + sed -i 's/upload_max_filesize = .*/upload_max_filesize = 512M/' "$ini" + sed -i 's/post_max_size = .*/post_max_size = 512M/' "$ini" + sed -i 's/memory_limit = .*/memory_limit = 512M/' "$ini" + sed -i 's/max_execution_time = .*/max_execution_time = 600/' "$ini" + sed -i 's/max_input_time = .*/max_input_time = 600/' "$ini" + sed -i 's/;date.timezone =.*/date.timezone = UTC/' "$ini" + fi + done + + # Enable necessary extensions + phpenmod -v ${PHP_VERSION} phar curl mbstring xml zip 2>/dev/null || true + + # Configure PHP-FPM www pool for large uploads + local www_pool="/etc/php/${PHP_VERSION}/fpm/pool.d/www.conf" + if [[ -f "$www_pool" ]]; then + # Remove existing settings if present + sed -i '/^php_admin_value\[upload_max_filesize\]/d' "$www_pool" + sed -i '/^php_admin_value\[post_max_size\]/d' "$www_pool" + sed -i '/^php_admin_value\[max_execution_time\]/d' "$www_pool" + sed -i '/^php_admin_value\[max_input_time\]/d' "$www_pool" + # Add upload settings + echo 'php_admin_value[upload_max_filesize] = 512M' >> "$www_pool" + echo 'php_admin_value[post_max_size] = 512M' >> "$www_pool" + echo 'php_admin_value[max_execution_time] = 600' >> "$www_pool" + echo 'php_admin_value[max_input_time] = 600' >> "$www_pool" + fi + + # Reload PHP-FPM + if systemctl reload php${PHP_VERSION}-fpm 2>/dev/null; then + log "PHP ${PHP_VERSION} configured" + elif systemctl reload php-fpm 2>/dev/null; then + log "PHP configured" + else + warn "Could not reload PHP-FPM, you may need to reload it manually" + fi +} + +# Configure MariaDB +configure_mariadb() { + header "Configuring MariaDB" + + systemctl enable mariadb + systemctl start mariadb + + # Secure installation (non-interactive) + mysql -e "DELETE FROM mysql.user WHERE User='';" + mysql -e "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');" + mysql -e "DROP DATABASE IF EXISTS test;" + mysql -e "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';" + mysql -e "FLUSH PRIVILEGES;" + + # Create Jabali database + local db_password=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) + mysql -e "CREATE DATABASE IF NOT EXISTS jabali CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql -e "CREATE USER IF NOT EXISTS 'jabali'@'localhost' IDENTIFIED BY '${db_password}';" + mysql -e "GRANT ALL PRIVILEGES ON jabali.* TO 'jabali'@'localhost';" + mysql -e "FLUSH PRIVILEGES;" + + # Save credentials + echo "DB_PASSWORD=${db_password}" > /root/.jabali_db_credentials + chmod 600 /root/.jabali_db_credentials + + log "MariaDB configured" + info "Database credentials saved to /root/.jabali_db_credentials" +} + +# Configure Nginx +configure_nginx() { + header "Configuring Nginx" + + # Remove default site + rm -f /etc/nginx/sites-enabled/default + + # Configure nginx global settings for large uploads + local nginx_conf="/etc/nginx/nginx.conf" + if [[ -f "$nginx_conf" ]]; then + # Add timeouts if not present + if ! grep -q "client_body_timeout" "$nginx_conf"; then + sed -i '/server_tokens/a\ client_max_body_size 512M;\n client_body_timeout 600s;\n fastcgi_read_timeout 600s;\n proxy_read_timeout 600s;' "$nginx_conf" + fi + + # Add FastCGI cache settings if not present + if ! grep -q "fastcgi_cache_path" "$nginx_conf"; then + # Create cache directory + mkdir -p /var/cache/nginx/fastcgi + chown www-data:www-data /var/cache/nginx/fastcgi + + # Add FastCGI cache configuration before the closing brace of http block + sed -i '/^http {/,/^}/ { + /include \/etc\/nginx\/sites-enabled\/\*;/a\ +\ +\t##\ +\t# FastCGI Cache\ +\t##\ +\ +\tfastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=JABALI:100m inactive=60m max_size=1g;\ +\tfastcgi_cache_key "$scheme$request_method$host$request_uri";\ +\tfastcgi_cache_use_stale error timeout invalid_header http_500 http_503; + }' "$nginx_conf" + fi + + # Enable gzip compression for all text-based content + if ! grep -q "gzip_types" "$nginx_conf"; then + # Uncomment existing gzip settings (with tab-indented comments) + sed -i 's/^[[:space:]]*# gzip_vary on;/\tgzip_vary on;/' "$nginx_conf" + sed -i 's/^[[:space:]]*# gzip_proxied any;/\tgzip_proxied any;/' "$nginx_conf" + sed -i 's/^[[:space:]]*# gzip_comp_level 6;/\tgzip_comp_level 6;/' "$nginx_conf" + sed -i 's/^[[:space:]]*# gzip_buffers 16 8k;/\tgzip_buffers 16 8k;/' "$nginx_conf" + sed -i 's/^[[:space:]]*# gzip_http_version 1.1;/\tgzip_http_version 1.1;/' "$nginx_conf" + sed -i 's/^[[:space:]]*# gzip_types .*/\tgzip_types text\/plain text\/css text\/xml text\/javascript application\/json application\/javascript application\/xml application\/xml+rss application\/x-javascript application\/vnd.ms-fontobject application\/x-font-ttf font\/opentype font\/woff font\/woff2 image\/svg+xml image\/x-icon;/' "$nginx_conf" + # Add gzip_min_length after gzip_types to avoid compressing tiny files + sed -i '/^[[:space:]]*gzip_types/a\ gzip_min_length 256;' "$nginx_conf" + fi + fi + + # Find PHP-FPM socket + local php_sock="" + local possible_sockets=( + "/var/run/php/php${PHP_VERSION}-fpm.sock" + "/var/run/php/php-fpm.sock" + "/run/php/php${PHP_VERSION}-fpm.sock" + "/run/php/php-fpm.sock" + ) + + for sock in "${possible_sockets[@]}"; do + if [[ -S "$sock" ]] || [[ -e "$sock" ]]; then + php_sock="$sock" + break + fi + done + + # If not found, try to find it + if [[ -z "$php_sock" ]]; then + php_sock=$(find /var/run/php /run/php -name "*.sock" 2>/dev/null | head -1) + fi + + # Default fallback + if [[ -z "$php_sock" ]]; then + php_sock="/var/run/php/php${PHP_VERSION}-fpm.sock" + warn "PHP socket not found, using default: $php_sock" + else + info "Using PHP socket: $php_sock" + fi + + # Generate self-signed SSL certificate for the panel + log "Generating self-signed SSL certificate..." + local ssl_dir="/etc/ssl/jabali" + mkdir -p "$ssl_dir" + + # Generate private key and self-signed certificate (valid for 10 years) + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout "$ssl_dir/panel.key" \ + -out "$ssl_dir/panel.crt" \ + -subj "/C=US/ST=State/L=City/O=Jabali Panel/CN=${SERVER_HOSTNAME:-localhost}" \ + 2>/dev/null + + chmod 600 "$ssl_dir/panel.key" + chmod 644 "$ssl_dir/panel.crt" + + # Ensure Jabali Nginx include files exist for WAF/Geo includes + local jabali_includes="/etc/nginx/jabali/includes" + mkdir -p "$jabali_includes" + if [[ ! -f "$jabali_includes/waf.conf" ]]; then + cat > "$jabali_includes/waf.conf" <<'EOF' +# Managed by Jabali +modsecurity off; +EOF + fi + if [[ ! -f "$jabali_includes/geo.conf" ]]; then + echo "# Managed by Jabali" > "$jabali_includes/geo.conf" + fi + + # Create Jabali site config with HTTPS and HTTP redirect + cat > /etc/nginx/sites-available/${SERVER_HOSTNAME} << NGINX +# Redirect HTTP to HTTPS +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name ${SERVER_HOSTNAME} _; + return 301 https://\$host\$request_uri; +} + +# HTTPS server +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + http2 on; + server_name ${SERVER_HOSTNAME} _; + root /var/www/jabali/public; + index index.php index.html; + + # SSL Configuration + ssl_certificate /etc/ssl/jabali/panel.crt; + ssl_certificate_key /etc/ssl/jabali/panel.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + client_max_body_size 512M; + + location / { + try_files \$uri \$uri/ /index.php?\$query_string; + } + + location ~ \.php\$ { + fastcgi_pass unix:${php_sock}; + fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name; + include fastcgi_params; + fastcgi_read_timeout 600; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + # Roundcube Webmail + location ^~ /webmail/ { + alias /var/lib/roundcube/public_html/; + index index.php; + + location ~ \.php\$ { + fastcgi_pass unix:${php_sock}; + fastcgi_param SCRIPT_FILENAME \$request_filename; + include fastcgi_params; + fastcgi_read_timeout 600; + } + } +} +NGINX + + ln -sf /etc/nginx/sites-available/${SERVER_HOSTNAME} /etc/nginx/sites-enabled/ + + # Create nginx pre-start script to ensure log directories exist + # This prevents nginx from failing if a user deletes their logs directory + cat > /usr/local/bin/nginx-ensure-logs << 'ENSURELOG' +#!/bin/bash +for conf in /etc/nginx/sites-enabled/*; do + if [ -f "$conf" ]; then + grep -oP '(access_log|error_log)\s+\K[^\s;]+' "$conf" 2>/dev/null | while read -r logpath; do + if [[ "$logpath" != "off" && "$logpath" != "/dev/null" && "$logpath" != syslog* && "$logpath" == /* ]]; then + logdir=$(dirname "$logpath") + if [ ! -d "$logdir" ]; then + mkdir -p "$logdir" + if [[ "$logdir" =~ ^/home/([^/]+)/domains/([^/]+)/logs$ ]]; then + username="${BASH_REMATCH[1]}" + id "$username" &>/dev/null && chown "$username:$username" "$logdir" + fi + fi + fi + done + fi +done +exit 0 +ENSURELOG + chmod +x /usr/local/bin/nginx-ensure-logs + + # Add systemd override to run the script before nginx starts + mkdir -p /etc/systemd/system/nginx.service.d + cat > /etc/systemd/system/nginx.service.d/ensure-logs.conf << 'OVERRIDE' +[Service] +ExecStartPre= +ExecStartPre=/usr/local/bin/nginx-ensure-logs +ExecStartPre=/usr/sbin/nginx -t -q -g 'daemon on; master_process on;' +OVERRIDE + systemctl daemon-reload + + nginx -t && systemctl reload nginx + + log "Nginx configured with HTTPS (self-signed certificate)" +} + +# Configure Mail Server +configure_mail() { + header "Configuring Mail Server" + + # Basic Postfix config + postconf -e "smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem" + postconf -e "smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key" + postconf -e "smtpd_tls_security_level=may" + postconf -e "smtpd_tls_auth_only=yes" + postconf -e "virtual_transport=lmtp:unix:private/dovecot-lmtp" + postconf -e "virtual_mailbox_domains=hash:/etc/postfix/virtual_mailbox_domains" + postconf -e "virtual_mailbox_maps=hash:/etc/postfix/virtual_mailbox_maps" + postconf -e "virtual_alias_maps=hash:/etc/postfix/virtual_aliases" + + # Create empty virtual map files and generate hash databases + touch /etc/postfix/virtual_mailbox_domains + touch /etc/postfix/virtual_mailbox_maps + touch /etc/postfix/virtual_aliases + postmap /etc/postfix/virtual_mailbox_domains + postmap /etc/postfix/virtual_mailbox_maps + postmap /etc/postfix/virtual_aliases + + # Configure submission port (587) for authenticated mail clients + if ! grep -q "^submission" /etc/postfix/master.cf; then + cat >> /etc/postfix/master.cf << 'SUBMISSION' + +# Submission port for authenticated mail clients +submission inet n - y - - smtpd + -o syslog_name=postfix/submission + -o smtpd_tls_security_level=encrypt + -o smtpd_sasl_auth_enable=yes + -o smtpd_sasl_type=dovecot + -o smtpd_sasl_path=private/auth + -o smtpd_client_restrictions=permit_sasl_authenticated,reject + -o milter_macro_daemon_name=ORIGINATING +SUBMISSION + fi + + # Basic Dovecot config + mkdir -p /var/mail/vhosts + groupadd -g 5000 vmail 2>/dev/null || true + useradd -g vmail -u 5000 vmail -d /var/mail 2>/dev/null || true + chown -R vmail:vmail /var/mail + + # Add dovecot to www-data group for SQLite access + usermod -a -G www-data dovecot 2>/dev/null || true + + # Configure Dovecot SQL authentication for SQLite (Dovecot 2.4 format) + info "Configuring Dovecot SQL authentication..." + cat > /etc/dovecot/conf.d/auth-sql.conf.ext << 'DOVECOT_SQL' +# Authentication for SQL users - Jabali Panel +# Dovecot 2.4 configuration format + +sql_driver = sqlite +sqlite_path = /var/www/jabali/database/database.sqlite + +passdb sql { + query = SELECT \ + m.local_part || '@' || d.domain AS user, \ + m.password_hash AS password \ + FROM mailboxes m \ + JOIN email_domains e ON m.email_domain_id = e.id \ + JOIN domains d ON e.domain_id = d.id \ + WHERE m.local_part || '@' || d.domain = '%{user}' \ + AND m.is_active = 1 \ + AND e.is_active = 1 +} + +userdb sql { + query = SELECT \ + m.maildir_path AS home, \ + m.system_uid AS uid, \ + m.system_gid AS gid \ + FROM mailboxes m \ + JOIN email_domains e ON m.email_domain_id = e.id \ + JOIN domains d ON e.domain_id = d.id \ + WHERE m.local_part || '@' || d.domain = '%{user}' \ + AND m.is_active = 1 + + iterate_query = SELECT m.local_part || '@' || d.domain AS user \ + FROM mailboxes m \ + JOIN email_domains e ON m.email_domain_id = e.id \ + JOIN domains d ON e.domain_id = d.id \ + WHERE m.is_active = 1 +} +DOVECOT_SQL + + # Configure Dovecot master user for Jabali SSO + if [[ ! -d /etc/jabali ]]; then + mkdir -p /etc/jabali + chown root:www-data /etc/jabali + chmod 750 /etc/jabali + fi + + master_user="jabali-master" + master_pass="" + + if [[ -f /etc/jabali/roundcube-sso.conf ]]; then + master_user=$(grep -m1 '^JABALI_SSO_MASTER_USER=' /etc/jabali/roundcube-sso.conf | cut -d= -f2-) + master_pass=$(grep -m1 '^JABALI_SSO_MASTER_PASS=' /etc/jabali/roundcube-sso.conf | cut -d= -f2-) + fi + + if [[ -z "$master_user" ]]; then + master_user="jabali-master" + fi + + if [[ -z "$master_pass" ]]; then + master_pass=$(openssl rand -hex 24) + cat > /etc/jabali/roundcube-sso.conf </dev/null 2>&1; then + master_hash=$(doveadm pw -s SHA512-CRYPT -p "$master_pass") + else + master_hash="{SHA512-CRYPT}$(openssl passwd -6 "$master_pass")" + fi + + cat > /etc/dovecot/master-users < /etc/dovecot/conf.d/auth-master.conf.ext << 'DOVECOT_MASTER' +passdb { + driver = passwd-file + args = /etc/dovecot/master-users + master = yes +} +DOVECOT_MASTER + + # Enable SQL auth in Dovecot (disable system auth, enable SQL auth) + if [[ -f /etc/dovecot/conf.d/10-auth.conf ]]; then + # Comment out system auth if not already + sed -i 's/^!include auth-system.conf.ext/#!include auth-system.conf.ext/' /etc/dovecot/conf.d/10-auth.conf + # Enable SQL auth if not already + if ! grep -q "^!include auth-sql.conf.ext" /etc/dovecot/conf.d/10-auth.conf; then + sed -i 's/#!include auth-sql.conf.ext/!include auth-sql.conf.ext/' /etc/dovecot/conf.d/10-auth.conf + # If the line doesn't exist at all, add it + if ! grep -q "auth-sql.conf.ext" /etc/dovecot/conf.d/10-auth.conf; then + echo "!include auth-sql.conf.ext" >> /etc/dovecot/conf.d/10-auth.conf + fi + fi + if ! grep -q "^auth_master_user_separator" /etc/dovecot/conf.d/10-auth.conf; then + echo "auth_master_user_separator = *" >> /etc/dovecot/conf.d/10-auth.conf + fi + if ! grep -q "auth-master.conf.ext" /etc/dovecot/conf.d/10-auth.conf; then + echo "!include auth-master.conf.ext" >> /etc/dovecot/conf.d/10-auth.conf + fi + fi + + # Configure Dovecot sockets for Postfix + if [[ -f /etc/dovecot/conf.d/10-master.conf ]]; then + # Add Postfix auth socket for SASL authentication + # Note: Check for our specific comment, not just the socket path (default config has commented example) + if ! grep -q "Postfix SMTP authentication socket" /etc/dovecot/conf.d/10-master.conf; then + cat >> /etc/dovecot/conf.d/10-master.conf << 'DOVECOT_AUTH' + +# Postfix SMTP authentication socket +service auth { + unix_listener /var/spool/postfix/private/auth { + mode = 0660 + user = postfix + group = postfix + } +} +DOVECOT_AUTH + fi + + # Add LMTP socket for Postfix mail delivery + # Note: Check for our specific comment + if ! grep -q "LMTP socket for Postfix mail delivery" /etc/dovecot/conf.d/10-master.conf; then + cat >> /etc/dovecot/conf.d/10-master.conf << 'DOVECOT_LMTP' + +# LMTP socket for Postfix mail delivery +service lmtp { + unix_listener /var/spool/postfix/private/dovecot-lmtp { + mode = 0600 + user = postfix + group = postfix + } +} +DOVECOT_LMTP + fi + fi + + # Fix LMTP auth_username_format to keep full email address + if [[ -f /etc/dovecot/conf.d/20-lmtp.conf ]]; then + sed -i 's/auth_username_format = %{user | username | lower}/#auth_username_format = %{user | username | lower}/' /etc/dovecot/conf.d/20-lmtp.conf + fi + + # Configure Dovecot mail storage (Maildir format) + cat > /etc/dovecot/conf.d/10-mail.conf << 'DOVECOT_MAIL' +## +## Mailbox locations and namespaces - Jabali Panel +## + +# Mail storage format and location +# Using Maildir format - home is returned by userdb +mail_driver = maildir +mail_home = %{userdb:home} +mail_path = %{userdb:home} + +namespace inbox { + inbox = yes + separator = / +} +DOVECOT_MAIL + + systemctl enable postfix dovecot + + # Create Roundcube SSO script for Jabali Panel + if [[ -d /var/lib/roundcube/public_html ]]; then + cat > /var/lib/roundcube/public_html/jabali-sso.php << 'RCUBE_SSO' + $data["expires"]) { + die("Token expired"); +} + +$masterUser = ""; +$masterPass = ""; +$masterConfig = "/etc/jabali/roundcube-sso.conf"; +if (is_readable($masterConfig)) { + $lines = file($masterConfig, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (!str_contains($line, "=")) { + continue; + } + [$key, $value] = explode("=", $line, 2); + $key = trim($key); + $value = trim($value); + if ($key === "JABALI_SSO_MASTER_USER") { + $masterUser = $value; + } elseif ($key === "JABALI_SSO_MASTER_PASS") { + $masterPass = $value; + } + } +} + +$useMaster = !empty($data["use_master"]); +if ($useMaster) { + $loginAs = trim($data["login_as"] ?? ""); + if ($loginAs === "" || $masterUser === "" || $masterPass === "") { + die("SSO master login is not configured"); + } + $authUser = $loginAs . "*" . $masterUser; + $authPass = $masterPass; +} else { + if (!isset($data["email"]) || !isset($data["password"])) { + die("Invalid token data"); + } + $authUser = trim($data["email"]); + $authPass = $data["password"]; +} + +// Initialize Roundcube +define("INSTALL_PATH", "/var/lib/roundcube/"); +require_once "/usr/share/roundcube/program/include/iniset.php"; + +$rcmail = rcmail::get_instance(0, "web"); + +// Perform direct login instead of form submission +$auth = $rcmail->plugins->exec_hook("authenticate", [ + "host" => $rcmail->autoselect_host(), + "user" => $authUser, + "pass" => $authPass, + "valid" => true, + "cookiecheck" => false, +]); + +if ($auth["valid"] && !$auth["abort"]) { + $login = $rcmail->login($auth["user"], $auth["pass"], $auth["host"], $auth["cookiecheck"]); + + if ($login) { + // Login successful - redirect to inbox + $rcmail->session->regenerate_id(false); + $rcmail->session->set_auth_cookie(); + header("Location: /webmail/?_task=mail"); + exit; + } +} + +// Login failed - show error +?> + + +Login Failed + +

Login failed. Please try again or contact support.

+

Go to webmail login

+ + +RCUBE_SSO + chmod 755 /var/lib/roundcube/public_html/jabali-sso.php + log "Roundcube SSO script installed" + fi + + # Configure Roundcube SMTP with TLS (use 127.0.0.1 instead of localhost for reliable TLS) + if [[ -f /etc/roundcube/config.inc.php ]]; then + sed -i "s|\$config\['smtp_host'\] = 'localhost:587';|\$config['smtp_host'] = 'tls://127.0.0.1:587';|g" /etc/roundcube/config.inc.php + sed -i "s|\$config\['smtp_host'\] = 'tls://localhost:587';|\$config['smtp_host'] = 'tls://127.0.0.1:587';|g" /etc/roundcube/config.inc.php + # Add SMTP SSL options if not present (disable cert verification for localhost) + if ! grep -q "smtp_conn_options" /etc/roundcube/config.inc.php; then + cat >> /etc/roundcube/config.inc.php << 'SMTP_SSL' + +// Disable TLS certificate verification for localhost SMTP +$config['smtp_conn_options'] = [ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], +]; +SMTP_SSL + fi + fi + if [[ -f /var/lib/roundcube/config/config.inc.php ]]; then + sed -i "s|\$config\['smtp_host'\] = 'localhost:587';|\$config['smtp_host'] = 'tls://127.0.0.1:587';|g" /var/lib/roundcube/config/config.inc.php + sed -i "s|\$config\['smtp_host'\] = 'tls://localhost:587';|\$config['smtp_host'] = 'tls://127.0.0.1:587';|g" /var/lib/roundcube/config/config.inc.php + fi + + # Fix Roundcube config file permissions so www-data can read them + # This is required for the SSO script to work + chown root:www-data /etc/roundcube/config.inc.php /etc/roundcube/debian-db.php 2>/dev/null + chmod 640 /etc/roundcube/config.inc.php /etc/roundcube/debian-db.php 2>/dev/null + chown root:www-data /var/lib/roundcube/config/config.inc.php /var/lib/roundcube/config/debian-db.php 2>/dev/null + chmod 640 /var/lib/roundcube/config/config.inc.php /var/lib/roundcube/config/debian-db.php 2>/dev/null + + # Fix Roundcube SQLite database configuration + # The default debian-db.php has empty $basepath which causes DB errors + if [[ -f /etc/roundcube/debian-db.php ]]; then + cat > /etc/roundcube/debian-db.php << 'RCUBE_DB' +/dev/null + fi + + # Configure OpenDKIM for DKIM signing + info "Configuring OpenDKIM..." + mkdir -p /etc/opendkim/keys + chown -R opendkim:opendkim /etc/opendkim + chmod 750 /etc/opendkim + + # Create OpenDKIM configuration files + touch /etc/opendkim/KeyTable + touch /etc/opendkim/SigningTable + cat > /etc/opendkim/TrustedHosts << 'TRUSTED' +127.0.0.1 +localhost +::1 +TRUSTED + chown opendkim:opendkim /etc/opendkim/KeyTable /etc/opendkim/SigningTable /etc/opendkim/TrustedHosts + chmod 644 /etc/opendkim/KeyTable /etc/opendkim/SigningTable /etc/opendkim/TrustedHosts + + # Configure OpenDKIM socket in Postfix chroot + mkdir -p /var/spool/postfix/opendkim + chown opendkim:postfix /var/spool/postfix/opendkim + chmod 750 /var/spool/postfix/opendkim + + # Update OpenDKIM configuration + if [[ -f /etc/opendkim.conf ]]; then + # Comment out the default Socket line (we'll add our own) + sed -i 's|^Socket.*local:/run/opendkim/opendkim.sock|#Socket local:/run/opendkim/opendkim.sock|' /etc/opendkim.conf + + # Add Jabali configuration if not present + if ! grep -q "^KeyTable" /etc/opendkim.conf; then + cat >> /etc/opendkim.conf << 'OPENDKIM_CONF' + +# Jabali Panel configuration +KeyTable /etc/opendkim/KeyTable +SigningTable refile:/etc/opendkim/SigningTable +InternalHosts /etc/opendkim/TrustedHosts +ExternalIgnoreList /etc/opendkim/TrustedHosts +Socket local:/var/spool/postfix/opendkim/opendkim.sock +OPENDKIM_CONF + fi + fi + + # Configure Postfix to use OpenDKIM milter + postconf -e "smtpd_milters = unix:opendkim/opendkim.sock" + postconf -e "non_smtpd_milters = unix:opendkim/opendkim.sock" + postconf -e "milter_default_action = accept" + + # Add postfix to opendkim group for socket access + usermod -aG opendkim postfix 2>/dev/null || true + + systemctl enable opendkim + + # Restart mail services to apply configuration + systemctl restart opendkim + systemctl restart dovecot + systemctl restart postfix + log "OpenDKIM configured" + + log "Mail server configured with SQLite authentication" +} + +# Create webmaster mailbox for system notifications +create_webmaster_mailbox() { + if [[ "$INSTALL_MAIL" != "true" ]]; then + info "Skipping webmaster mailbox creation (mail server not installed)" + return + fi + + header "Creating Webmaster Mailbox" + + local webmaster_password=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9!@#$%' | head -c 16) + + # Extract root domain for email (e.g., panel.example.com -> example.com) + local dot_count=$(echo "$SERVER_HOSTNAME" | tr -cd '.' | wc -c) + local email_domain="$SERVER_HOSTNAME" + if [[ $dot_count -gt 1 ]]; then + email_domain=$(echo "$SERVER_HOSTNAME" | awk -F. '{print $(NF-1)"."$NF}') + fi + + cd "$JABALI_DIR" + php artisan tinker --execute=" + use App\Models\Domain; + use App\Models\EmailDomain; + use App\Models\Mailbox; + use App\Models\User; + use App\Services\Agent\AgentClient; + use Illuminate\Support\Facades\Crypt; + + \$hostname = '${email_domain}'; + \$password = '${webmaster_password}'; + + // Find admin user + \$admin = User::where('is_admin', true)->first(); + if (!\$admin) { + echo 'No admin user found, skipping webmaster mailbox'; + return; + } + + // Create or find domain for hostname + \$domain = Domain::firstOrCreate( + ['domain' => \$hostname], + ['user_id' => \$admin->id, 'status' => 'active', 'document_root' => '/var/www/html'] + ); + + // Enable email for domain if not already + \$agent = new AgentClient(); + try { + \$agent->emailEnableDomain(\$admin->username, \$hostname); + } catch (Exception \$e) { + // Domain might already be enabled + } + + // Create EmailDomain record + \$emailDomain = EmailDomain::firstOrCreate( + ['domain_id' => \$domain->id], + ['is_active' => true] + ); + + // Check if webmaster mailbox exists + if (Mailbox::where('email_domain_id', \$emailDomain->id)->where('local_part', 'webmaster')->exists()) { + echo 'Webmaster mailbox already exists'; + return; + } + + // Create mailbox via agent + \$result = \$agent->mailboxCreate(\$admin->username, 'webmaster@' . \$hostname, \$password, 1073741824); + + Mailbox::create([ + 'email_domain_id' => \$emailDomain->id, + 'user_id' => \$admin->id, + 'local_part' => 'webmaster', + 'password_hash' => \$result['password_hash'] ?? '', + 'password_encrypted' => Crypt::encryptString(\$password), + 'maildir_path' => \$result['maildir_path'] ?? null, + 'system_uid' => \$result['uid'] ?? null, + 'system_gid' => \$result['gid'] ?? null, + 'name' => 'System Webmaster', + 'quota_bytes' => 1073741824, + 'is_active' => true, + ]); + + echo 'Webmaster mailbox created successfully'; + " 2>/dev/null || true + + log "Webmaster mailbox: webmaster@${SERVER_HOSTNAME}" + log "Webmaster password: ${webmaster_password}" + + # Save credentials + echo "" >> /root/jabali_credentials.txt + echo "=== Webmaster Email ===" >> /root/jabali_credentials.txt + echo "Email: webmaster@${SERVER_HOSTNAME}" >> /root/jabali_credentials.txt + echo "Password: ${webmaster_password}" >> /root/jabali_credentials.txt +} + +# Configure SSH login notifications via PAM +configure_ssh_notifications() { + header "Configuring SSH Login Notifications" + + # Create PAM hook script in /etc/security + local pam_script="/etc/security/jabali-ssh-notify.sh" + + cat > "$pam_script" << 'PAMSCRIPT' +#!/bin/bash +# Jabali SSH Login Notification Hook +# Called by PAM on successful SSH authentication + +# Only run on successful authentication +if [ "$PAM_TYPE" != "open_session" ]; then + exit 0 +fi + +# Get the username and IP +USERNAME="$PAM_USER" +IP="${PAM_RHOST:-unknown}" + +# Determine auth method +if [ -n "$SSH_AUTH_INFO_0" ]; then + case "$SSH_AUTH_INFO_0" in + publickey*) METHOD="publickey" ;; + password*) METHOD="password" ;; + keyboard-interactive*) METHOD="keyboard-interactive" ;; + *) METHOD="password" ;; + esac +else + METHOD="password" +fi + +# Run the notification command in background +cd /var/www/jabali && /usr/bin/php artisan notify:ssh-login "$USERNAME" "$IP" --method="$METHOD" > /dev/null 2>&1 & + +exit 0 +PAMSCRIPT + + chmod +x "$pam_script" + + # Add to PAM sshd configuration if not already present + local pam_sshd="/etc/pam.d/sshd" + if ! grep -q "jabali-ssh-notify" "$pam_sshd" 2>/dev/null; then + echo "# Jabali SSH login notification" >> "$pam_sshd" + echo "session optional pam_exec.so quiet /etc/security/jabali-ssh-notify.sh" >> "$pam_sshd" + log "Added SSH login notification hook to PAM" + else + info "SSH login notification hook already configured" + fi + + log "SSH login notifications configured" +} + +# Configure DNS Zone for server hostname +configure_dns() { + header "Configuring DNS Server" + + local server_ip=$(hostname -I | awk '{print $1}') + + # Extract domain from hostname (e.g., panel.example.com -> example.com) + local domain=$(echo "$SERVER_HOSTNAME" | awk -F. '{if (NF>2) {print $(NF-1)"."$NF} else {print $0}}') + local hostname_part=$(echo "$SERVER_HOSTNAME" | sed "s/\.$domain$//") + + # If hostname is same as domain (e.g., example.com), use @ for the host part + if [[ "$hostname_part" == "$domain" ]]; then + hostname_part="@" + fi + + info "Setting up DNS zone for: $domain" + info "Server hostname: $SERVER_HOSTNAME" + + # Create zones directory + mkdir -p /etc/bind/zones + + # Generate serial (YYYYMMDD01 format) + local serial=$(date +%Y%m%d)01 + + # Create zone file + cat > /etc/bind/zones/db.$domain << ZONEFILE +\$TTL 3600 +@ IN SOA ns1.$domain. admin.$domain. ( + $serial ; Serial + 3600 ; Refresh + 1800 ; Retry + 604800 ; Expire + 86400 ) ; Negative Cache TTL + +; Name servers +@ IN NS ns1.$domain. +@ IN NS ns2.$domain. + +; A records +@ IN A $server_ip +ns1 IN A $server_ip +ns2 IN A $server_ip +ZONEFILE + + # Add hostname A record if it's a subdomain + if [[ "$hostname_part" != "@" ]]; then + echo "$hostname_part IN A $server_ip" >> /etc/bind/zones/db.$domain + fi + + # Add mail records + cat >> /etc/bind/zones/db.$domain << MAILRECORDS + +; Mail records +@ IN MX 10 mail.$domain. +mail IN A $server_ip + +; SPF record +@ IN TXT "v=spf1 mx a ip4:$server_ip ~all" + +; DMARC record +_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:admin@$domain" + +; Autodiscover for mail clients +autoconfig IN A $server_ip +autodiscover IN A $server_ip +MAILRECORDS + + # Set permissions + chown bind:bind /etc/bind/zones/db.$domain + chmod 644 /etc/bind/zones/db.$domain + + # Add zone to named.conf.local if not already present + if ! grep -q "zone \"$domain\"" /etc/bind/named.conf.local 2>/dev/null; then + cat >> /etc/bind/named.conf.local << NAMEDCONF + +zone "$domain" { + type master; + file "/etc/bind/zones/db.$domain"; + allow-transfer { none; }; +}; +NAMEDCONF + fi + + # Configure BIND9 options for better security + if [[ ! -f /etc/bind/named.conf.options.bak ]]; then + cp /etc/bind/named.conf.options /etc/bind/named.conf.options.bak + cat > /etc/bind/named.conf.options << 'BINDOPTIONS' +options { + directory "/var/cache/bind"; + + // Forward queries to public DNS if not authoritative + forwarders { + 8.8.8.8; + 8.8.4.4; + }; + + // Security settings + dnssec-validation auto; + auth-nxdomain no; + listen-on-v6 { any; }; + + // Allow queries from anywhere (for authoritative zones) + allow-query { any; }; + + // Disable recursion for security (authoritative only) + recursion no; +}; +BINDOPTIONS + fi + + # Enable and restart BIND9 + systemctl enable named 2>/dev/null || systemctl enable bind9 2>/dev/null || true + systemctl restart named 2>/dev/null || systemctl restart bind9 2>/dev/null || true + + log "DNS zone created for $domain" + info "Zone file: /etc/bind/zones/db.$domain" + echo "" + echo -e "${YELLOW}Important DNS Setup:${NC}" + echo "Point your domain's nameservers to this server:" + echo " ns1.$domain -> $server_ip" + echo " ns2.$domain -> $server_ip" + echo "" + echo "Or add a glue record at your registrar for ns1.$domain" + echo "" +} + +# Setup Disk Quotas +setup_quotas() { + header "Setting Up Disk Quotas" + + # Check if quotas are already enabled on root filesystem + if mount | grep ' / ' | grep -q 'usrquota'; then + log "Quotas already enabled on root filesystem" + return 0 + fi + + # Get the root filesystem device + local root_dev=$(findmnt -n -o SOURCE /) + local root_mount=$(findmnt -n -o TARGET /) + + if [[ -z "$root_dev" ]]; then + warn "Could not determine root filesystem device" + return 1 + fi + + info "Enabling quotas on $root_dev ($root_mount)..." + + # Check filesystem type + local fs_type=$(findmnt -n -o FSTYPE /) + if [[ "$fs_type" != "ext4" && "$fs_type" != "ext3" && "$fs_type" != "xfs" ]]; then + warn "Quotas not supported on $fs_type filesystem" + return 1 + fi + + # For ext4, try to enable quota feature directly (modern approach) + if [[ "$fs_type" == "ext4" ]]; then + # Check if quota feature is available + if tune2fs -l "$root_dev" 2>/dev/null | grep -q "quota"; then + info "Using ext4 native quota feature..." + fi + fi + + # Add quota options to fstab if not present + if ! grep -E "^\s*$root_dev|^\s*UUID=" /etc/fstab | grep -q 'usrquota'; then + info "Adding quota options to /etc/fstab..." + + # Backup fstab + cp /etc/fstab /etc/fstab.backup.$(date +%Y%m%d_%H%M%S) + + # Add quota options to root mount + # Use # as delimiter since | is used as regex OR and $root_dev contains / + sed -i -E "s#^([^#]*\s+/\s+\S+\s+)(\S+)#\1\2,usrquota,grpquota#" /etc/fstab + + # If sed didn't change anything (different fstab format), try another approach + if ! grep ' / ' /etc/fstab | grep -q 'usrquota'; then + # Try to add to defaults + sed -i -E "s#^(\s*\S+\s+/\s+\S+\s+)defaults#\1defaults,usrquota,grpquota#" /etc/fstab + fi + fi + + # Remount with quota options + info "Remounting root filesystem with quota options..." + mount -o remount,usrquota,grpquota / 2>/dev/null || { + warn "Could not remount with quotas - may require reboot" + } + + # Check if quota tools work + if mount | grep ' / ' | grep -q 'usrquota'; then + # Create quota files if needed + quotacheck -avugm 2>/dev/null || true + + # Enable quotas + quotaon -avug 2>/dev/null || true + + if quotaon -p / 2>/dev/null | grep -q "is on"; then + log "Disk quotas enabled successfully" + else + warn "Quotas configured but may require reboot to activate" + fi + else + warn "Quota options not applied - may require reboot" + fi +} + +# Configure Firewall +configure_firewall() { + header "Configuring Firewall" + + if ! command -v ufw &> /dev/null; then + warn "UFW not found, skipping firewall configuration" + return + fi + + ufw --force reset + ufw default deny incoming + ufw default allow outgoing + ufw allow 22/tcp # SSH + ufw allow 80/tcp # HTTP + ufw allow 443/tcp # HTTPS + + # Mail ports (only if mail server is being installed) + if [[ "$INSTALL_MAIL" == "true" ]]; then + ufw allow 25/tcp # SMTP + ufw allow 465/tcp # SMTPS (SMTP over SSL) + ufw allow 587/tcp # Submission (SMTP with STARTTLS) + ufw allow 110/tcp # POP3 + ufw allow 143/tcp # IMAP + ufw allow 993/tcp # IMAPS (IMAP over SSL) + ufw allow 995/tcp # POP3S (POP3 over SSL) + fi + + # DNS (only if DNS server is being installed) + if [[ "$INSTALL_DNS" == "true" ]]; then + ufw allow 53/tcp # DNS + ufw allow 53/udp # DNS + fi + + ufw --force enable + + log "Firewall configured" +} + +# Configure Security Tools (Fail2ban and ClamAV) +configure_security() { + header "Configuring Security Tools" + + # Install ModSecurity + CRS (optional) + if [[ "$INSTALL_SECURITY" == "true" ]]; then + info "Installing ModSecurity (optional WAF)..." + local module_pkg="" + if apt-cache show libnginx-mod-http-modsecurity &>/dev/null; then + module_pkg="libnginx-mod-http-modsecurity" + elif apt-cache show libnginx-mod-http-modsecurity2 &>/dev/null; then + module_pkg="libnginx-mod-http-modsecurity2" + elif apt-cache show nginx-extras &>/dev/null; then + module_pkg="nginx-extras" + else + warn "ModSecurity nginx module not available in apt repositories" + fi + + local modsec_lib="" + if apt-cache show libmodsecurity3t64 &>/dev/null; then + modsec_lib="libmodsecurity3t64" + elif apt-cache show libmodsecurity3 &>/dev/null; then + modsec_lib="libmodsecurity3" + fi + + local crs_pkg="" + if apt-cache show modsecurity-crs &>/dev/null; then + crs_pkg="modsecurity-crs" + fi + + if [[ -n "$module_pkg" ]]; then + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$module_pkg" $modsec_lib $crs_pkg 2>/dev/null || warn "ModSecurity install failed" + + # Ensure ModSecurity base config + if [[ ! -f /etc/modsecurity/modsecurity.conf ]]; then + if [[ -f /etc/nginx/modsecurity.conf ]]; then + cp /etc/nginx/modsecurity.conf /etc/modsecurity/modsecurity.conf + elif [[ -f /etc/modsecurity/modsecurity.conf-recommended ]]; then + cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf + elif [[ -f /usr/share/modsecurity-crs/modsecurity.conf-recommended ]]; then + cp /usr/share/modsecurity-crs/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf + else + cat > /etc/modsecurity/modsecurity.conf <<'EOF' +SecRuleEngine DetectionOnly +SecRequestBodyAccess On +SecResponseBodyAccess Off +SecAuditEngine RelevantOnly +SecAuditLog /var/log/nginx/modsec_audit.log +EOF + fi + fi + + # Create main include file for nginx if missing + mkdir -p /etc/nginx/modsec + if [[ ! -f /etc/nginx/modsec/main.conf ]]; then + if [[ -f /usr/share/modsecurity-crs/owasp-crs.load ]]; then + cat > /etc/nginx/modsec/main.conf <<'EOF' +Include /etc/modsecurity/modsecurity.conf +Include /usr/share/modsecurity-crs/owasp-crs.load +EOF + elif [[ -f /etc/modsecurity/crs/crs-setup.conf ]]; then + cat > /etc/nginx/modsec/main.conf <<'EOF' +Include /etc/modsecurity/modsecurity.conf +Include /etc/modsecurity/crs/crs-setup.conf +Include /usr/share/modsecurity-crs/rules/*.conf +EOF + else + cat > /etc/nginx/modsec/main.conf <<'EOF' +Include /etc/modsecurity/modsecurity.conf +EOF + fi + fi + fi + fi + + # Configure Fail2ban + info "Configuring Fail2ban..." + cat > /etc/fail2ban/jail.local << 'FAIL2BAN' +[DEFAULT] +bantime = 600 +findtime = 600 +maxretry = 5 +ignoreip = 127.0.0.1/8 ::1 + +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 5 + +[nginx-http-auth] +enabled = true +port = http,https +filter = nginx-http-auth +logpath = /var/log/nginx/error.log + +[nginx-botsearch] +enabled = true +port = http,https +filter = nginx-botsearch +logpath = /var/log/nginx/access.log +maxretry = 2 +FAIL2BAN + + # Create WordPress filter and jail + cat > /etc/fail2ban/filter.d/wordpress.conf << 'WPFILTER' +[Definition] +failregex = ^ -.*"POST.*/wp-login\.php.*" (200|403) + ^ -.*"POST.*/xmlrpc\.php.*" (200|403) +ignoreregex = action=logout +WPFILTER + + cat > /etc/fail2ban/jail.d/wordpress.conf << 'WPJAIL' +[wordpress] +enabled = false +filter = wordpress +port = http,https +logpath = /home/*/domains/*/logs/access.log + /var/log/nginx/access.log +maxretry = 5 +findtime = 300 +bantime = 3600 +WPJAIL + + # Create mail service jails (disabled by default, enabled when mail is configured) + cat > /etc/fail2ban/jail.d/dovecot.conf << 'DOVECOTJAIL' +[dovecot] +enabled = false +filter = dovecot +backend = systemd +maxretry = 5 +findtime = 300 +bantime = 3600 +DOVECOTJAIL + + cat > /etc/fail2ban/jail.d/postfix.conf << 'POSTFIXJAIL' +[postfix] +enabled = false +filter = postfix +mode = more +backend = systemd +maxretry = 5 +findtime = 300 +bantime = 3600 + +[postfix-sasl] +enabled = false +filter = postfix[mode=auth] +backend = systemd +maxretry = 3 +findtime = 300 +bantime = 3600 +POSTFIXJAIL + + cat > /etc/fail2ban/jail.d/roundcube.conf << 'RCJAIL' +[roundcube-auth] +enabled = false +filter = roundcube-auth +port = http,https +logpath = /var/lib/roundcube/logs/errors.log +maxretry = 5 +findtime = 300 +bantime = 3600 +RCJAIL + + systemctl enable fail2ban + systemctl restart fail2ban + log "Fail2ban configured and enabled" + + # Configure SSH Jail for users + info "Configuring SSH/SFTP jail..." + + # Create groups for SFTP and shell users + groupadd sftpusers 2>/dev/null || true + groupadd shellusers 2>/dev/null || true + + # Backup original sshd_config + cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup.$(date +%Y%m%d%H%M%S) + + # Remove any existing Jabali SSH config + sed -i '/# Jabali SSH Jail Configuration/,/# End Jabali SSH Jail/d' /etc/ssh/sshd_config + + # Add SSH jail configuration + cat >> /etc/ssh/sshd_config << 'SSHJAIL' + +# Jabali SSH Jail Configuration +# SFTP-only users (default for all panel users) +Match Group sftpusers + ChrootDirectory /home/%u + ForceCommand internal-sftp + AllowTcpForwarding no + AllowAgentForwarding no + PermitTTY no + X11Forwarding no + +# Shell users (jailed with limited commands) +Match Group shellusers + ChrootDirectory /var/jail + AllowTcpForwarding no + AllowAgentForwarding no + X11Forwarding no +# End Jabali SSH Jail +SSHJAIL + + # Restart SSH to apply changes + systemctl restart sshd + log "SSH jail configured" + + # Set up jail environment for shell users + info "Setting up jail environment..." + + # Create jail directory structure + mkdir -p /var/jail/{bin,lib,lib64,usr,etc,dev,home} + mkdir -p /var/jail/usr/{bin,lib,share} + mkdir -p /var/jail/usr/lib/x86_64-linux-gnu + + # Copy essential binaries to jail + JAIL_BINS="bash sh ls cat cp mv rm mkdir rmdir pwd echo head tail grep sed awk wc sort uniq cut tr touch chmod find which" + for bin in $JAIL_BINS; do + if [ -f "/bin/$bin" ]; then + cp /bin/$bin /var/jail/bin/ 2>/dev/null || true + elif [ -f "/usr/bin/$bin" ]; then + cp /usr/bin/$bin /var/jail/usr/bin/ 2>/dev/null || true + fi + done + + # Copy wp-cli if available + if [ -f "/usr/local/bin/wp" ]; then + cp /usr/local/bin/wp /var/jail/usr/bin/wp + fi + + # Copy PHP for wp-cli support + if [ -f "/usr/bin/php" ]; then + cp /usr/bin/php /var/jail/usr/bin/ + fi + + # Copy env (needed by wp-cli shebang) + if [ -f "/usr/bin/env" ]; then + cp /usr/bin/env /var/jail/usr/bin/ + fi + + # Copy required libraries for binaries + copy_libs_for_binary() { + local binary="$1" + if [ -f "$binary" ]; then + ldd "$binary" 2>/dev/null | grep -o '/[^ ]*' | while read lib; do + if [ -f "$lib" ]; then + local libdir=$(dirname "$lib") + mkdir -p "/var/jail$libdir" + cp -n "$lib" "/var/jail$libdir/" 2>/dev/null || true + fi + done + fi + } + + for bin in /var/jail/bin/* /var/jail/usr/bin/*; do + [ -f "$bin" ] && copy_libs_for_binary "$bin" + done + + # Copy PHP extensions for wp-cli + PHP_EXT_DIR=$(php -i 2>/dev/null | grep "^extension_dir" | awk '{print $3}') + if [ -d "$PHP_EXT_DIR" ]; then + mkdir -p "/var/jail$PHP_EXT_DIR" + cp "$PHP_EXT_DIR"/*.so "/var/jail$PHP_EXT_DIR/" 2>/dev/null || true + + # Copy extension library dependencies + for ext in "$PHP_EXT_DIR"/*.so; do + [ -f "$ext" ] && copy_libs_for_binary "$ext" + done + fi + + # Copy PHP CLI configuration (resolve symlinks to actual files) + mkdir -p /var/jail/etc/php/8.4/cli/conf.d + cp /etc/php/8.4/cli/php.ini /var/jail/etc/php/8.4/cli/ 2>/dev/null || true + + # Set timezone in jail's php.ini to avoid warnings + sed -i 's/;date.timezone =/date.timezone = UTC/' /var/jail/etc/php/8.4/cli/php.ini 2>/dev/null || true + + for f in /etc/php/8.4/cli/conf.d/*.ini; do + if [ -L "$f" ]; then + # Resolve symlink and copy actual file + cp "$(readlink -f "$f")" "/var/jail/etc/php/8.4/cli/conf.d/$(basename "$f")" 2>/dev/null || true + elif [ -f "$f" ]; then + cp "$f" "/var/jail/etc/php/8.4/cli/conf.d/" 2>/dev/null || true + fi + done + + # Create essential device nodes + mknod -m 666 /var/jail/dev/null c 1 3 2>/dev/null || true + mknod -m 666 /var/jail/dev/zero c 1 5 2>/dev/null || true + mknod -m 666 /var/jail/dev/random c 1 8 2>/dev/null || true + mknod -m 666 /var/jail/dev/urandom c 1 9 2>/dev/null || true + mknod -m 666 /var/jail/dev/tty c 5 0 2>/dev/null || true + + # Create minimal /etc files + grep -E "^(root|nobody)" /etc/passwd > /var/jail/etc/passwd + grep -E "^(root|nogroup)" /etc/group > /var/jail/etc/group + cp /etc/nsswitch.conf /var/jail/etc/ 2>/dev/null || true + cp /etc/hosts /var/jail/etc/ 2>/dev/null || true + + # Copy timezone data for PHP + mkdir -p /var/jail/usr/share/zoneinfo + cp -r /usr/share/zoneinfo/* /var/jail/usr/share/zoneinfo/ 2>/dev/null || true + ln -sf /usr/share/zoneinfo/UTC /var/jail/etc/localtime 2>/dev/null || true + + # Set permissions + chown root:root /var/jail + chmod 755 /var/jail + + log "Jail environment configured with wp-cli support" + + # Configure ClamAV only if INSTALL_SECURITY is enabled + if [[ "$INSTALL_SECURITY" != "true" ]]; then + log "Skipping ClamAV configuration (security tools not selected)" + return + fi + + # Configure ClamAV (disabled by default to save memory) + info "Configuring ClamAV (disabled by default)..." + + # Create quarantine directory + mkdir -p /var/lib/clamav/quarantine + chown clamav:clamav /var/lib/clamav/quarantine + chmod 750 /var/lib/clamav/quarantine + + # Create optimized ClamAV configuration + cat > /etc/clamav/clamd.conf << 'CLAMAV_CONF' +# Jabali ClamAV Configuration - Optimized for low resource usage +LocalSocket /var/run/clamav/clamd.ctl +FixStaleSocket true +LocalSocketGroup clamav +LocalSocketMode 666 +User clamav +LogFile /var/log/clamav/clamav.log +LogRotate true +LogTime true +DatabaseDirectory /var/lib/clamav +MaxThreads 2 +MaxScanSize 25M +MaxFileSize 5M +MaxRecursion 10 +ScanArchive false +ScanPE true +ScanELF true +ScanHTML true +AlgorithmicDetection true +PhishingSignatures true +Foreground false +ExitOnOOM true +CLAMAV_CONF + + # Configure freshclam + cat > /etc/clamav/freshclam.conf << 'FRESHCLAM_CONF' +DatabaseDirectory /var/lib/clamav +UpdateLogFile /var/log/clamav/freshclam.log +LogRotate true +LogTime true +DatabaseMirror database.clamav.net +NotifyClamd /etc/clamav/clamd.conf +Checks 4 +FRESHCLAM_CONF + + # Stop ClamAV services - keep them disabled by default + systemctl stop clamav-daemon 2>/dev/null || true + systemctl stop clamav-freshclam 2>/dev/null || true + systemctl disable clamav-daemon 2>/dev/null || true + systemctl disable clamav-freshclam 2>/dev/null || true + + # Update signatures once during install + info "Downloading initial virus signatures..." + freshclam --quiet 2>/dev/null || warn "Could not download virus signatures" + + log "ClamAV configured (disabled by default to save memory)" + info "Enable ClamAV via Security Center in Admin Panel when needed" +} + +# Configure Redis with ACL +configure_redis() { + header "Configuring Redis" + + # Generate random admin password + REDIS_ADMIN_PASSWORD=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) + + # Save credentials for later use + cat > /root/.jabali_redis_credentials << CREDS +REDIS_ADMIN_PASSWORD=${REDIS_ADMIN_PASSWORD} +CREDS + chmod 600 /root/.jabali_redis_credentials + + # Configure Redis with ACL + cat > /etc/redis/redis.conf << 'REDIS_CONF' +# Redis Configuration for Jabali Panel + +# Network +bind 127.0.0.1 +port 6379 +protected-mode yes + +# General +daemonize yes +pidfile /var/run/redis/redis-server.pid +loglevel notice +logfile /var/log/redis/redis-server.log + +# Persistence +save 900 1 +save 300 10 +save 60 10000 +stop-writes-on-bgsave-error yes +rdbcompression yes +rdbchecksum yes +dbfilename dump.rdb +dir /var/lib/redis + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# ACL - Disable default user, require authentication +aclfile /etc/redis/users.acl + +# Clients +timeout 0 +tcp-keepalive 300 +REDIS_CONF + + # Create ACL file with admin user (no comments allowed in Redis 8 ACL files) + cat > /etc/redis/users.acl << ACL_CONF +user default off +user jabali_admin on >${REDIS_ADMIN_PASSWORD} ~* &* +@all +ACL_CONF + + # Remove any comments or empty lines (Redis 8 doesn't allow them) + sed -i '/^#/d; /^$/d' /etc/redis/users.acl + + chmod 640 /etc/redis/users.acl + chown redis:redis /etc/redis/users.acl + + # Restart Redis + systemctl restart redis-server + + # Check if Redis started successfully + if ! systemctl is-active --quiet redis-server; then + error "Redis failed to start. Check /var/log/redis/redis-server.log" + exit 1 + fi + + log "Redis configured with ACL authentication" +} + +# Setup Jabali Panel +setup_jabali() { + header "Setting Up Jabali Panel" + + cd "$JABALI_DIR" + + # Load database credentials + source /root/.jabali_db_credentials + + # Load Redis credentials + source /root/.jabali_redis_credentials + + # Create .env file + cp .env.example .env 2>/dev/null || cat > .env << ENV +APP_NAME=Jabali +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://$(hostname -I | awk '{print $1}') + +LOG_CHANNEL=stack +LOG_LEVEL=error + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=jabali +DB_USERNAME=jabali +DB_PASSWORD=${DB_PASSWORD} + +CACHE_DRIVER=redis +SESSION_DRIVER=redis +QUEUE_CONNECTION=redis + +REDIS_HOST=127.0.0.1 +REDIS_USERNAME=jabali_admin +REDIS_PASSWORD=${REDIS_ADMIN_PASSWORD} +REDIS_PORT=6379 + +MAIL_MAILER=sendmail +MAIL_FROM_ADDRESS=webmaster@${SERVER_HOSTNAME} +MAIL_FROM_NAME="Jabali Panel" +ENV + + # Ensure mail settings are correct (in case .env.example was used) + sed -i "s/^MAIL_MAILER=.*/MAIL_MAILER=sendmail/" .env + sed -i "s/^MAIL_FROM_ADDRESS=.*/MAIL_FROM_ADDRESS=webmaster@${SERVER_HOSTNAME}/" .env + + # Install dependencies + log "Installing Composer dependencies..." + sudo -u $JABALI_USER COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader + if [ ! -f "$JABALI_DIR/vendor/autoload.php" ]; then + error "Composer install failed - vendor/autoload.php not found" + exit 1 + fi + + # Set storage permissions BEFORE running artisan commands + # This prevents files being created as root and becoming inaccessible to www-data + log "Setting storage permissions..." + mkdir -p "$JABALI_DIR/storage/logs" + mkdir -p "$JABALI_DIR/storage/framework/cache/data" + mkdir -p "$JABALI_DIR/storage/framework/sessions" + mkdir -p "$JABALI_DIR/storage/framework/views" + mkdir -p "$JABALI_DIR/storage/app/public" + mkdir -p "$JABALI_DIR/bootstrap/cache" + touch "$JABALI_DIR/storage/logs/laravel.log" + chown -R www-data:www-data "$JABALI_DIR/storage" + chown -R www-data:www-data "$JABALI_DIR/bootstrap/cache" + chmod -R 775 "$JABALI_DIR/storage" + chmod -R 775 "$JABALI_DIR/bootstrap/cache" + + # Generate app key + php artisan key:generate --force + + # Run migrations + php artisan migrate --force + + # Create storage symlink for public files (screenshots, etc.) + php artisan storage:link --force 2>/dev/null || true + + # Publish and configure Livewire for large file uploads + php artisan livewire:publish --config 2>/dev/null || php artisan vendor:publish --tag=livewire:config 2>/dev/null || true + if [[ -f "$JABALI_DIR/config/livewire.php" ]]; then + sed -i "s/'rules' => null,/'rules' => ['required', 'file', 'max:524288'],/" "$JABALI_DIR/config/livewire.php" + sed -i "s/'max_upload_time' => 5,/'max_upload_time' => 15,/" "$JABALI_DIR/config/livewire.php" + fi + + # Configure DNS settings with server hostname and IP + local server_ip=$(hostname -I | awk '{print $1}') + + # Extract root domain from hostname (e.g., panel.example.com -> example.com) + # Count the number of dots to determine if it's a subdomain + local dot_count=$(echo "$SERVER_HOSTNAME" | tr -cd '.' | wc -c) + if [[ $dot_count -gt 1 ]]; then + # It's a subdomain - extract root domain (last two parts) + local root_domain=$(echo "$SERVER_HOSTNAME" | awk -F. '{print $(NF-1)"."$NF}') + local subdomain_part=$(echo "$SERVER_HOSTNAME" | sed "s/\.$root_domain$//") + log "Detected subdomain installation: $subdomain_part.$root_domain" + else + # It's already a root domain + local root_domain="$SERVER_HOSTNAME" + local subdomain_part="" + fi + + log "Configuring DNS settings for ${SERVER_HOSTNAME} (root: ${root_domain}, IP: ${server_ip})..." + + php artisan tinker --execute=" + use App\Models\DnsSetting; + DnsSetting::set('ns1', 'ns1.${root_domain}'); + DnsSetting::set('ns2', 'ns2.${root_domain}'); + DnsSetting::set('ns1_ip', '${server_ip}'); + DnsSetting::set('ns2_ip', '${server_ip}'); + DnsSetting::set('default_ip', '${server_ip}'); + DnsSetting::set('admin_email', 'admin.${root_domain}'); + DnsSetting::set('default_ttl', '3600'); + " 2>/dev/null || true + + # Create DNS zone for root domain + log "Creating DNS zone for ${root_domain}..." + + # Build records array - include subdomain A record if installing on subdomain + local records_json="[ + {\"name\": \"@\", \"type\": \"NS\", \"content\": \"ns1.${root_domain}\", \"ttl\": 3600}, + {\"name\": \"@\", \"type\": \"NS\", \"content\": \"ns2.${root_domain}\", \"ttl\": 3600}, + {\"name\": \"@\", \"type\": \"A\", \"content\": \"${server_ip}\", \"ttl\": 3600}, + {\"name\": \"www\", \"type\": \"A\", \"content\": \"${server_ip}\", \"ttl\": 3600}, + {\"name\": \"ns1\", \"type\": \"A\", \"content\": \"${server_ip}\", \"ttl\": 3600}, + {\"name\": \"ns2\", \"type\": \"A\", \"content\": \"${server_ip}\", \"ttl\": 3600}, + {\"name\": \"mail\", \"type\": \"A\", \"content\": \"${server_ip}\", \"ttl\": 3600}, + {\"name\": \"@\", \"type\": \"MX\", \"content\": \"mail.${root_domain}\", \"ttl\": 3600, \"priority\": 10}, + {\"name\": \"@\", \"type\": \"TXT\", \"content\": \"v=spf1 mx a ~all\", \"ttl\": 3600}, + {\"name\": \"_dmarc\", \"type\": \"TXT\", \"content\": \"v=DMARC1; p=none; rua=mailto:postmaster@${root_domain}\", \"ttl\": 3600}" + + # Add subdomain A record if installing on a subdomain + if [[ -n "$subdomain_part" ]]; then + records_json="${records_json}, + {\"name\": \"${subdomain_part}\", \"type\": \"A\", \"content\": \"${server_ip}\", \"ttl\": 3600}" + fi + records_json="${records_json}]" + + php artisan tinker --execute=" + \$agent = new App\Services\Agent\AgentClient(); + \$agent->send('dns.sync_zone', [ + 'domain' => '${root_domain}', + 'records' => json_decode('${records_json}', true), + 'ns1' => 'ns1.${root_domain}', + 'ns2' => 'ns2.${root_domain}', + 'admin_email' => 'admin.${root_domain}', + 'default_ip' => '${server_ip}', + 'default_ttl' => 3600, + ]); + " 2>/dev/null || true + + # Build assets + npm install + npm run build + + # Final permissions - ensure everything is correct after all setup + chown -R $JABALI_USER:www-data "$JABALI_DIR" + chown -R www-data:www-data "$JABALI_DIR/storage" + chown -R www-data:www-data "$JABALI_DIR/bootstrap/cache" + chmod -R 775 "$JABALI_DIR/storage" + chmod -R 775 "$JABALI_DIR/bootstrap/cache" + + # Set SQLite database permissions for Dovecot access (if mail server is installed) + if [[ "$INSTALL_MAIL" == "true" ]] && [[ -f "$JABALI_DIR/database/database.sqlite" ]]; then + info "Setting SQLite database permissions for mail server..." + chmod 664 "$JABALI_DIR/database/database.sqlite" + chmod 775 "$JABALI_DIR/database" + fi + + # Create CLI symlink + ln -sf "$JABALI_DIR/bin/jabali" /usr/local/bin/jabali + chmod +x "$JABALI_DIR/bin/jabali" + chmod +x "$JABALI_DIR/bin/jabali-agent" + + # Update version file + cat > "$JABALI_DIR/VERSION" << EOF +VERSION=${JABALI_VERSION} +EOF + chown $JABALI_USER:$JABALI_USER "$JABALI_DIR/VERSION" + + # Create agent directories + mkdir -p /var/run/jabali + mkdir -p /var/log/jabali + chown $JABALI_USER:$JABALI_USER /var/run/jabali + chown $JABALI_USER:$JABALI_USER /var/log/jabali + + # Create backup directories + mkdir -p /var/backups/jabali + mkdir -p /var/backups/jabali/cpanel-migrations + mkdir -p /var/backups/jabali/whm-migrations + chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali + chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations + + log "Jabali Panel setup complete" +} + +# Create systemd service for agent +setup_agent_service() { + header "Setting Up Jabali Agent Service" + + cat > /etc/systemd/system/jabali-agent.service << 'SERVICE' +[Unit] +Description=Jabali Panel Agent +After=network.target + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/bin/php /var/www/jabali/bin/jabali-agent +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +SERVICE + + systemctl daemon-reload + systemctl enable jabali-agent + systemctl start jabali-agent + + log "Jabali Agent service configured" +} + +setup_queue_service() { + header "Setting Up Jabali Queue Worker" + + cat > /etc/systemd/system/jabali-queue.service << 'SERVICE' +[Unit] +Description=Jabali Queue Worker +After=network.target jabali-agent.service +Wants=jabali-agent.service + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/jabali +ExecStart=/usr/bin/php /var/www/jabali/artisan queue:work --sleep=3 --tries=1 --timeout=120 +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=jabali-queue + +[Install] +WantedBy=multi-user.target +SERVICE + + systemctl daemon-reload + systemctl enable jabali-queue + systemctl start jabali-queue + + log "Jabali Queue Worker service configured" +} + +# Setup Laravel scheduler cron job +setup_scheduler_cron() { + header "Setting Up Laravel Scheduler" + + # Create log directory if it doesn't exist + mkdir -p "$JABALI_DIR/storage/logs" + chown -R www-data:www-data "$JABALI_DIR/storage/logs" + + # Add cron job for Laravel scheduler as www-data (runs every minute) + CRON_LINE="* * * * * cd $JABALI_DIR && php artisan schedule:run >> /dev/null 2>&1" + + # Add to www-data's crontab (not root) to avoid permission issues with log files + if ! sudo -u www-data crontab -l 2>/dev/null | grep -q "artisan schedule:run"; then + (sudo -u www-data crontab -l 2>/dev/null; echo "$CRON_LINE") | sudo -u www-data crontab - + log "Laravel scheduler cron job added" + else + log "Laravel scheduler cron job already exists" + fi + + log "Laravel scheduler configured - SSL auto-renewal and backups will run automatically" +} + +# Setup logrotate for user domain logs +setup_logrotate() { + header "Setting Up Log Rotation" + + cat > /etc/logrotate.d/jabali-users << 'LOGROTATE' +# Logrotate configuration for Jabali Panel user domain logs +/home/*/domains/*/logs/*.log { + daily + missingok + rotate 14 + compress + delaycompress + notifempty + create 0644 root root + sharedscripts + postrotate + invoke-rc.d nginx rotate >/dev/null 2>&1 + endscript +} +LOGROTATE + + log "Log rotation configured for user domain logs" +} + +# Setup SSL certificates for panel and mail services +setup_panel_ssl() { + header "Setting Up SSL Certificates for Services" + + # Get public IP (try external service first, fall back to hostname -I) + local server_ip=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ipv4.icanhazip.com 2>/dev/null || hostname -I | awk '{print $1}') + server_ip=$(echo "$server_ip" | tr -d '[:space:]') + + # Check if hostname resolves to this server (required for Let's Encrypt) + local resolved_ip=$(dig +short "$SERVER_HOSTNAME" 2>/dev/null | head -1) + + if [[ -z "$resolved_ip" ]]; then + warn "Cannot resolve $SERVER_HOSTNAME - skipping Let's Encrypt" + warn "SSL can be issued later using: certbot --nginx -d $SERVER_HOSTNAME" + return + fi + + if [[ "$resolved_ip" != "$server_ip" ]]; then + warn "Hostname $SERVER_HOSTNAME resolves to $resolved_ip, not this server ($server_ip)" + warn "SSL can be issued later using: certbot --nginx -d $SERVER_HOSTNAME" + return + fi + + info "Attempting to issue Let's Encrypt certificate for $SERVER_HOSTNAME" + + # Issue certificate and configure nginx automatically + if certbot --nginx -d "$SERVER_HOSTNAME" --non-interactive --agree-tos --email "$ADMIN_EMAIL" --redirect 2>&1; then + log "Let's Encrypt certificate issued and nginx configured for $SERVER_HOSTNAME" + export PANEL_SSL_INSTALLED=true + else + warn "Could not issue Let's Encrypt certificate for $SERVER_HOSTNAME" + warn "This may be because the domain is not publicly accessible" + warn "You can issue manually later: certbot --nginx -d $SERVER_HOSTNAME" + fi + + # Try to issue certificate for mail hostname if different + local mail_hostname="mail.$(echo "$SERVER_HOSTNAME" | awk -F. '{if(NF>2){for(i=2;i<=NF;i++)printf "%s%s",$i,(i/dev/null | head -1) + if [[ "$mail_resolved" == "$server_ip" ]]; then + info "Attempting to issue Let's Encrypt certificate for $mail_hostname" + if certbot certonly --standalone -d "$mail_hostname" --non-interactive --agree-tos --email "$ADMIN_EMAIL" 2>/dev/null; then + log "Let's Encrypt certificate issued for $mail_hostname" + + # Update Postfix to use the certificate + postconf -e "smtpd_tls_cert_file=/etc/letsencrypt/live/$mail_hostname/fullchain.pem" + postconf -e "smtpd_tls_key_file=/etc/letsencrypt/live/$mail_hostname/privkey.pem" + systemctl reload postfix 2>/dev/null || true + + # Update Dovecot to use the certificate + if [[ -f /etc/dovecot/conf.d/10-ssl.conf ]]; then + sed -i "s|^ssl_cert = .*|ssl_cert = /dev/null || true + fi + log "Mail services updated to use Let's Encrypt certificate" + else + warn "Could not issue certificate for $mail_hostname" + fi + fi + fi + + log "SSL setup complete" +} + +# Setup self-healing services (automatic restart on failure) +setup_self_healing() { + header "Setting Up Self-Healing Services" + + # List of critical services to harden with restart policies + local services=( + "nginx" + "mariadb" + "jabali-agent" + "jabali-queue" + ) + + # Add PHP-FPM (detect version) + for version in 8.4 8.3 8.2 8.1 8.0; do + if systemctl list-unit-files "php${version}-fpm.service" &>/dev/null | grep -q "php${version}-fpm"; then + services+=("php${version}-fpm") + break + fi + done + + # Add optional services if installed + if systemctl list-unit-files postfix.service &>/dev/null | grep -q postfix; then + services+=("postfix") + fi + if systemctl list-unit-files dovecot.service &>/dev/null | grep -q dovecot; then + services+=("dovecot") + fi + if systemctl list-unit-files named.service &>/dev/null | grep -q named; then + services+=("named") + elif systemctl list-unit-files bind9.service &>/dev/null | grep -q bind9; then + services+=("bind9") + fi + if systemctl list-unit-files redis-server.service &>/dev/null | grep -q redis-server; then + services+=("redis-server") + fi + if systemctl list-unit-files fail2ban.service &>/dev/null | grep -q fail2ban; then + services+=("fail2ban") + fi + + # Create systemd override directory and restart policy for each service + for service in "${services[@]}"; do + local override_dir="/etc/systemd/system/${service}.service.d" + mkdir -p "$override_dir" + + cat > "${override_dir}/restart.conf" << 'OVERRIDE' +[Unit] +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Restart=always +RestartSec=5 +OVERRIDE + + log "Added restart policy for $service" + done + + # Reload systemd to apply overrides + systemctl daemon-reload + + # Setup health monitor service + cat > /etc/systemd/system/jabali-health-monitor.service << 'SERVICE' +[Unit] +Description=Jabali Health Monitor - Automatic service recovery +Documentation=https://github.com/shukiv/jabali-panel +After=network.target jabali-agent.service +Wants=jabali-agent.service + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/bin/php /var/www/jabali/bin/jabali-health-monitor +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=jabali-health-monitor + +# Resource limits +LimitNOFILE=65535 +MemoryMax=128M + +[Install] +WantedBy=multi-user.target +SERVICE + + # Enable and start health monitor + systemctl daemon-reload + systemctl enable jabali-health-monitor + systemctl start jabali-health-monitor + + log "Self-healing services configured" + log "Health monitor running - check /var/log/jabali/health-monitor.log for events" +} + +# Create admin user +create_admin() { + header "Creating Admin User" + + cd "$JABALI_DIR" + + # Generate admin password and export for print_completion + export ADMIN_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) + + php artisan tinker --execute=" + \$user = App\Models\User::create([ + 'name' => 'Administrator', + 'username' => 'admin', + 'email' => '${ADMIN_EMAIL}', + 'password' => bcrypt('${ADMIN_PASSWORD}'), + 'is_admin' => true, + ]); + " 2>/dev/null || true + + # Save credentials + echo "ADMIN_EMAIL=${ADMIN_EMAIL}" >> /root/.jabali_db_credentials + echo "ADMIN_PASSWORD=${ADMIN_PASSWORD}" >> /root/.jabali_db_credentials +} + +# Print completion message +print_completion() { + local server_ip=$(hostname -I | awk '{print $1}') + + echo "" + echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ ${BOLD}Jabali Panel Installation Complete!${NC}${GREEN} ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╠════════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ ║${NC}" + printf "${GREEN}║${NC} Version: ${BOLD}%-46s${NC} ${GREEN}║${NC}\n" "v${JABALI_VERSION}" + printf "${GREEN}║${NC} Hostname: ${BOLD}%-45s${NC} ${GREEN}║${NC}\n" "${SERVER_HOSTNAME}" + printf "${GREEN}║${NC} Server IP: ${BOLD}%-44s${NC} ${GREEN}║${NC}\n" "${server_ip}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╠════════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║${NC} ${BOLD}Admin Credentials:${NC} ${GREEN}║${NC}" + printf "${GREEN}║${NC} Email: ${BOLD}%-43s${NC} ${GREEN}║${NC}\n" "${ADMIN_EMAIL}" + printf "${GREEN}║${NC} Password: ${BOLD}%-43s${NC} ${GREEN}║${NC}\n" "${ADMIN_PASSWORD}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╠════════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║${NC} ${BOLD}Panel URLs:${NC} ${GREEN}║${NC}" + printf "${GREEN}║${NC} Admin Panel: ${BOLD}%-40s${NC} ${GREEN}║${NC}\n" "https://${SERVER_HOSTNAME}/jabali-admin/" + printf "${GREEN}║${NC} User Panel: ${BOLD}%-40s${NC} ${GREEN}║${NC}\n" "https://${SERVER_HOSTNAME}/jabali-panel/" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╠════════════════════════════════════════════════════════════╣${NC}" + if [[ "$PANEL_SSL_INSTALLED" == "true" ]]; then + echo -e "${GREEN}║${NC} ${GREEN}SSL: Let's Encrypt certificate installed${NC} ${GREEN}║${NC}" + else + echo -e "${GREEN}║${NC} ${YELLOW}Note: Using self-signed SSL certificate.${NC} ${GREEN}║${NC}" + echo -e "${GREEN}║${NC} ${YELLOW}Browser will show a security warning - this is normal.${NC} ${GREEN}║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║${NC} ${CYAN}Get a free SSL certificate:${NC} ${GREEN}║${NC}" + printf "${GREEN}║${NC} ${CYAN}%-56s${NC} ${GREEN}║${NC}\n" "certbot --nginx -d ${SERVER_HOSTNAME}" + fi + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║${NC} CLI Usage: ${CYAN}jabali --help${NC} ${GREEN}║${NC}" + echo -e "${GREEN}║${NC} Credentials: ${CYAN}/root/.jabali_db_credentials${NC} ${GREEN}║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# Uninstall Jabali Panel +uninstall() { + local force_uninstall=false + + # Check for --force flag + if [[ "$1" == "--force" ]] || [[ "$1" == "-f" ]]; then + force_uninstall=true + fi + + show_banner + check_root + + echo -e "${RED}${BOLD}WARNING: This will completely remove Jabali Panel and all related services!${NC}" + echo "" + echo "This will remove:" + echo " - Jabali Panel files (/var/www/jabali)" + echo " - Jabali database and user" + echo " - Nginx, PHP-FPM, MariaDB, Redis" + echo " - Mail server (Postfix, Dovecot, Rspamd)" + echo " - DNS server (BIND9)" + echo " - All user home directories (/home/*)" + echo " - All virtual mail (/var/mail)" + echo " - All domains and configurations" + echo "" + echo -e "${YELLOW}This action cannot be undone!${NC}" + echo "" + + if [[ "$force_uninstall" == "false" ]]; then + # First confirmation + read -p "Are you sure you want to uninstall? (y/N): " confirm1 < /dev/tty + if [[ ! "$confirm1" =~ ^[Yy]$ ]]; then + info "Uninstall cancelled" + exit 0 + fi + + echo "" + echo -e "${RED}${BOLD}FINAL WARNING: ALL DATA WILL BE PERMANENTLY DELETED!${NC}" + echo "" + + # Second confirmation - require typing + read -p "Type 'YES DELETE EVERYTHING' to confirm: " confirm2 < /dev/tty + if [[ "$confirm2" != "YES DELETE EVERYTHING" ]]; then + info "Uninstall cancelled" + exit 0 + fi + else + warn "Force mode enabled - skipping confirmations" + fi + + header "Stopping Services" + systemctl stop jabali-agent 2>/dev/null || true + systemctl disable jabali-agent 2>/dev/null || true + rm -f /etc/systemd/system/jabali-agent.service + rm -rf /etc/systemd/system/jabali-agent.service.d + + systemctl stop jabali-health-monitor 2>/dev/null || true + systemctl disable jabali-health-monitor 2>/dev/null || true + rm -f /etc/systemd/system/jabali-health-monitor.service + rm -rf /etc/systemd/system/jabali-health-monitor.service.d + + systemctl stop jabali-queue 2>/dev/null || true + systemctl disable jabali-queue 2>/dev/null || true + rm -f /etc/systemd/system/jabali-queue.service + rm -rf /etc/systemd/system/jabali-queue.service.d + + local services=( + nginx + php-fpm + php8.4-fpm + mariadb + mysql + redis-server + postfix + dovecot + rspamd + opendkim + bind9 + named + fail2ban + clamav-daemon + clamav-freshclam + ) + + for service in "${services[@]}"; do + systemctl stop "$service" 2>/dev/null || true + systemctl disable "$service" 2>/dev/null || true + done + + # Remove systemd restart overrides + rm -rf /etc/systemd/system/nginx.service.d/restart.conf + rm -rf /etc/systemd/system/mariadb.service.d/restart.conf + rm -rf /etc/systemd/system/jabali-agent.service.d/restart.conf + rm -rf /etc/systemd/system/jabali-queue.service.d/restart.conf + rm -rf /etc/systemd/system/php*.service.d/restart.conf + rm -rf /etc/systemd/system/postfix.service.d/restart.conf + rm -rf /etc/systemd/system/dovecot.service.d/restart.conf + rm -rf /etc/systemd/system/named.service.d/restart.conf + rm -rf /etc/systemd/system/bind9.service.d/restart.conf + rm -rf /etc/systemd/system/redis-server.service.d/restart.conf + rm -rf /etc/systemd/system/fail2ban.service.d/restart.conf + + systemctl daemon-reload + + header "Removing Jabali Panel" + rm -rf "$JABALI_DIR" + rm -f /usr/local/bin/jabali + rm -rf /var/run/jabali + rm -rf /var/log/jabali + rm -rf /var/backups/jabali + rm -f /root/.jabali_db_credentials + rm -f /root/.jabali_redis_credentials + log "Jabali Panel removed" + + header "Removing Database" + mysql -e "DROP DATABASE IF EXISTS jabali;" 2>/dev/null || true + mysql -e "DROP USER IF EXISTS 'jabali'@'localhost';" 2>/dev/null || true + log "Database removed" + + header "Removing Packages" + + # Set non-interactive mode to prevent dialog prompts + export DEBIAN_FRONTEND=noninteractive + + # Pre-configure debconf to remove databases without prompting + echo "mariadb-server mysql-server/remove-data-dir boolean true" | debconf-set-selections 2>/dev/null || true + echo "mariadb-server-10.5 mysql-server/remove-data-dir boolean true" | debconf-set-selections 2>/dev/null || true + echo "mariadb-server-10.6 mysql-server/remove-data-dir boolean true" | debconf-set-selections 2>/dev/null || true + echo "mariadb-server-10.11 mysql-server/remove-data-dir boolean true" | debconf-set-selections 2>/dev/null || true + + local packages=( + # Web Server + nginx + nginx-common + + # PHP + 'php*' + + # Database + mariadb-server + mariadb-client + mariadb-common + + # Cache + redis-server + + # Mail Server + postfix + postfix-mysql + dovecot-core + dovecot-imapd + dovecot-pop3d + dovecot-lmtpd + dovecot-mysql + opendkim + opendkim-tools + rspamd + + # DNS + bind9 + bind9-utils + + # Webmail + roundcube + roundcube-core + roundcube-mysql + roundcube-sqlite3 + roundcube-plugins + + # Security + fail2ban + clamav + clamav-daemon + clamav-freshclam + ) + + for pkg in "${packages[@]}"; do + DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq $pkg 2>/dev/null || true + done + + DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -qq + DEBIAN_FRONTEND=noninteractive apt-get autoclean -y -qq + + log "Packages removed" + + header "Cleaning Up Files" + # Web server + rm -rf /etc/nginx + rm -rf /var/cache/nginx + rm -rf /etc/php + rm -rf /var/lib/php + rm -rf /var/log/nginx + + # Database + rm -rf /var/lib/mysql + rm -rf /var/lib/redis + rm -rf /var/log/mysql + + # Mail server + rm -rf /etc/postfix + rm -rf /etc/dovecot + rm -rf /etc/opendkim + rm -rf /etc/rspamd + rm -rf /var/mail + rm -rf /var/vmail + rm -rf /var/spool/postfix + rm -rf /var/log/mail.* + + # DNS + rm -rf /etc/bind + rm -rf /var/cache/bind + rm -rf /var/log/named + + # Webmail (Roundcube) + rm -rf /etc/roundcube + rm -rf /var/lib/roundcube + rm -rf /var/log/roundcube + rm -rf /usr/share/roundcube + + # Security + rm -rf /etc/fail2ban + rm -rf /var/lib/fail2ban + rm -rf /var/log/fail2ban.log* + rm -rf /var/lib/clamav + rm -rf /var/log/clamav + + # SSL certificates (Let's Encrypt) + rm -rf /etc/letsencrypt + + # PHP repository + rm -f /etc/apt/sources.list.d/php.list + rm -f /usr/share/keyrings/sury-php.gpg + + # Jabali-specific configs + rm -rf /var/backups/users + rm -f /etc/logrotate.d/jabali-users + + # Remove www-data cron jobs (Laravel scheduler) + crontab -u www-data -r 2>/dev/null || true + + log "Configuration files cleaned" + + header "Removing User Data" + read -p "Remove all user home directories? (y/N): " remove_homes < /dev/tty + if [[ "$remove_homes" =~ ^[Yy]$ ]]; then + # Get list of normal users (UID >= 1000, excluding nobody) + for user_home in /home/*; do + if [[ -d "$user_home" ]]; then + username=$(basename "$user_home") + userdel -r "$username" 2>/dev/null || rm -rf "$user_home" + log "Removed user: $username" + fi + done + fi + + # Remove vmail user + userdel vmail 2>/dev/null || true + groupdel vmail 2>/dev/null || true + + header "Resetting Firewall" + if command -v ufw &> /dev/null; then + ufw --force reset + ufw --force disable + else + info "UFW not installed, skipping firewall reset" + fi + + echo "" + echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ ${BOLD}Jabali Panel Uninstallation Complete!${NC}${GREEN} ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║${NC} All Jabali Panel components have been removed. ${GREEN}║${NC}" + echo -e "${GREEN}║${NC} Your server is now clean. ${GREEN}║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# Show usage +show_usage() { + echo "Jabali Panel Installer" + echo "" + echo "Usage: $0 [command] [options]" + echo "" + echo "Commands:" + echo " install Install Jabali Panel (default, interactive)" + echo " uninstall [--force] Remove Jabali Panel and all components" + echo " --help Show this help message" + echo "" + echo "Environment Variables (for non-interactive install):" + echo " SERVER_HOSTNAME Set the server hostname" + echo " JABALI_FULL Install all components (set to any value)" + echo " JABALI_MINIMAL Install only core components (set to any value)" + echo "" + echo "Installation Modes:" + echo " Full Installation - Web, Mail, DNS, Firewall, Security tools" + echo " Minimal Installation - Web server only (Nginx, PHP, MariaDB, Redis, UFW)" + echo " Custom Installation - Choose individual components interactively" + echo "" + echo "Examples:" + echo "" + echo " Interactive install (prompts for options):" + echo " curl -fsSL http://192.168.100.100:3001/shukivaknin/jabali-panel/raw/branch/main/install_from_gitea.sh | sudo bash" + echo "" + echo " Full install (non-interactive):" + echo " SERVER_HOSTNAME=panel.example.com JABALI_FULL=1 curl -fsSL ... | sudo bash" + echo "" + echo " Minimal install (non-interactive):" + echo " SERVER_HOSTNAME=panel.example.com JABALI_MINIMAL=1 curl -fsSL ... | sudo bash" + echo "" + echo " Uninstall:" + echo " curl -fsSL http://192.168.100.100:3001/shukivaknin/jabali-panel/raw/branch/main/install_from_gitea.sh | sudo bash -s -- uninstall" + echo "" + echo " Force uninstall (no prompts):" + echo " curl -fsSL http://192.168.100.100:3001/shukivaknin/jabali-panel/raw/branch/main/install_from_gitea.sh | sudo bash -s -- uninstall --force" + echo "" +} + +# Main installation +main() { + show_banner + check_root + check_os + + local queue_was_active=false + if systemctl is-active --quiet jabali-queue; then + queue_was_active=true + systemctl stop jabali-queue 2>/dev/null || true + fi + + prompt_hostname + select_features + + add_repositories + install_packages + install_geoipupdate_binary + install_composer + clone_jabali + configure_php + configure_mariadb + configure_nginx + + # Optional components based on feature selection + if [[ "$INSTALL_MAIL" == "true" ]]; then + configure_mail + else + info "Skipping Mail Server configuration" + fi + + if [[ "$INSTALL_DNS" == "true" ]]; then + configure_dns + else + info "Skipping DNS Server configuration" + fi + + if [[ "$INSTALL_FIREWALL" == "true" ]]; then + configure_firewall + else + info "Skipping Firewall configuration" + fi + + # Setup disk quotas for user space management + setup_quotas + + # Always configure fail2ban and SSH jails (ClamAV is conditional inside) + configure_security + + # Enable fail2ban mail jails if mail server was installed + if [[ "$INSTALL_MAIL" == "true" ]]; then + info "Enabling fail2ban mail protection jails..." + if [[ -f /etc/fail2ban/jail.d/dovecot.conf ]]; then + sed -i 's/^enabled = false/enabled = true/' /etc/fail2ban/jail.d/dovecot.conf + fi + if [[ -f /etc/fail2ban/jail.d/postfix.conf ]]; then + sed -i 's/^enabled = false/enabled = true/' /etc/fail2ban/jail.d/postfix.conf + fi + # Only enable roundcube jail if log file exists + if [[ -f /etc/fail2ban/jail.d/roundcube.conf ]]; then + # Create roundcube logs directory if roundcube is installed + if [[ -d /var/lib/roundcube ]]; then + mkdir -p /var/lib/roundcube/logs + touch /var/lib/roundcube/logs/errors.log + chown -R www-data:www-data /var/lib/roundcube/logs + fi + # Only enable if log file exists + if [[ -f /var/lib/roundcube/logs/errors.log ]]; then + sed -i 's/^enabled = false/enabled = true/' /etc/fail2ban/jail.d/roundcube.conf + fi + fi + systemctl reload fail2ban 2>/dev/null || systemctl restart fail2ban 2>/dev/null || true + log "Fail2ban mail jails enabled" + fi + + configure_redis + setup_jabali + setup_agent_service + setup_queue_service + setup_scheduler_cron + setup_logrotate + setup_panel_ssl + setup_self_healing + create_admin + create_webmaster_mailbox + configure_ssh_notifications + + if [[ "$queue_was_active" == "true" ]]; then + systemctl start jabali-queue 2>/dev/null || true + fi + + print_completion +} + +# Parse command line arguments +case "${1:-install}" in + install) + main + ;; + uninstall|remove|purge) + uninstall "$2" + ;; + --help|-h|help) + show_usage + ;; + *) + error "Unknown command: $1" + show_usage + exit 1 + ;; +esac