#!/bin/bash ################################################################################ # OpenPanel Installer ✌️ # https://openpanel.com/install # # Supported OS: Ubuntu, Debian, AlmaLinux, RockyLinux, CentOS # Supported Architecture: x86_64(AMD64), AArch64(ARM64) # # Usage: bash <(curl -sSL https://openpanel.org/) # Author: Stefan Pejcic # Created: 11.07.2023 # Last Modified: 30.07.2026 ################################################################################ # shellcheck disable=SC2015 GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; RESET='\033[0m' export TERM=xterm-256color DEBIAN_FRONTEND=noninteractive # defaults PANEL_VERSION="" CUSTOM_VERSION=false ADMIN_PORT=2087 USER_PORT=2083 SKIP_APT_UPDATE=false SKIP_DNS_SERVER=false SKIP_FIREWALL=false REPAIR=false SET_HOSTNAME_NOW=false USE_SELFSIGNED=false SETUP_SWAP_ANYWAY=false CORAZA=true IMUNIFY_AV=false SWAP_FILE=1 SEND_EMAIL_AFTER_INSTALL=false SET_PREMIUM=false SET_ADMIN_USERNAME=false SET_ADMIN_PASSWORD=false LICENSE="Community" post_install_path="" new_hostname="" separate_panel_domain="" custom_username="" custom_password="" EMAIL="" license_key="" readonly DEFAULT_PANEL_VERSION="2.0.0" readonly CONTAINER_ENGINE="podman" readonly SHARED_STORE="/var/lib/containers/shared-storage" readonly ETC_DIR="/etc/openpanel/" readonly LOG_FILE="openpanel_install.log" readonly SERVICES_DIR="/etc/systemd/system/" readonly CONFIG_FILE="${ETC_DIR}openpanel/conf/openpanel.config" exec > >(tee -a "$LOG_FILE") 2>&1 echo "" > /root/openpanel_restart_needed ok() { echo -e "[${GREEN} OK ${RESET}] $*"; } warn() { echo -e "[${YELLOW} ! ${RESET}] $*"; } fail() { echo -e "[${RED} X ${RESET}] $*"; } die() { echo -e "${RED}INSTALLATION FAILED${RESET} - Please retry with '--repair' flag to retry. You can also add '-x' right after bash command to see the actual progress of this script and the exact errors. \nError message from the script: $2" >&2 exit 1 } run() { local ts; ts=$(date +'%Y-%m-%d %H:%M:%S') echo "[$ts] COMMAND: $*" >> "$LOG_FILE" "$@" >/dev/null 2>&1 } declare -A _PKG_CACHE=() _build_pkg_cache() { case "$PACKAGE_MANAGER" in apt-get) while IFS= read -r line; do [[ "$line" =~ ^ii[[:space:]]+([^[:space:]]+) ]] && _PKG_CACHE["${BASH_REMATCH[1]%%:*}"]=1 done < <(dpkg -l 2>/dev/null) ;; yum|dnf) while IFS= read -r pkg; do _PKG_CACHE["$pkg"]=1 done < <("$PACKAGE_MANAGER" list installed 2>/dev/null | awk 'NR>1{sub(/\.[^.]+$/,"",$1); print $1}') ;; esac } pkg_installed() { [[ "${_PKG_CACHE[$1]+_}" ]] || [[ "${_PKG_CACHE[${1%%:*}]+_}" ]] } line() { printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' -; } show_help() { cat < License key for OpenPanel Enterprise edition. --domain= Domain for OpenAdmin and OpenPanel. --panel-domain= Separate domain just for OpenPanel UI. --username= Admin username (random if not provided). --password= Admin password (random if not provided). --version= Custom OpenPanel version to install. --email= Email to receive admin credentials. --admin-port= Port for OpenAdmin (default: 2087). --user-port= Port for OpenPanel (default: 2083). --imunifyav Install and set up ImunifyAV. --skip-requirements Skip requirements check. --skip-panel-check Skip check for existing panels. --skip-apt-update Skip package manager update. --skip-firewall Skip Sentinel Firewall installation. --skip-dns-server Skip DNS (Bind9) setup. --no-waf Disable CorazaWAF / OWASP ruleset. --post_install= Post-install script path or URL. --swap=<1-10> Swap size in GB. --selfsigned Use a self-signed SSL certificate. --repair | --retry Retry and overwrite existing installation. -h, --help Show this help message. EOF } validate_port() { local name=$1 val=$2 if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1000 && val <= 30000 )); then echo "$val" else echo "Error: $name must be between 1000 and 30000" >&2; exit 1 fi } parse_args() { while [[ $# -gt 0 ]]; do case $1 in --key=*) SET_PREMIUM=true; license_key="${1#*=}" ;; --domain=*) SET_HOSTNAME_NOW=true; new_hostname="${1#*=}" ;; --panel-domain=*) SET_HOSTNAME_NOW=true; separate_panel_domain="${1#*=}" ;; --username=*) SET_ADMIN_USERNAME=true; custom_username="${1#*=}" ;; --password=*) SET_ADMIN_PASSWORD=true; custom_password="${1#*=}" ;; --post_install=*) post_install_path="${1#*=}" ;; --version=*) CUSTOM_VERSION=true; PANEL_VERSION="${1#*=}" ;; --swap=*) SETUP_SWAP_ANYWAY=true; SWAP_FILE="${1#*=}" ;; --email=*) SEND_EMAIL_AFTER_INSTALL=true; EMAIL="${1#*=}" ;; --admin-port=*) ADMIN_PORT=$(validate_port "admin-port" "${1#*=}") ;; --user-port=*) USER_PORT=$(validate_port "user-port" "${1#*=}") ;; --skip-requirements) SKIP_REQUIREMENTS=true ;; --skip-panel-check) SKIP_PANEL_CHECK=true ;; --skip-apt-update) SKIP_APT_UPDATE=true ;; --skip-dns-server) SKIP_DNS_SERVER=true ;; --skip-firewall) SKIP_FIREWALL=true ;; --imunifyav) IMUNIFY_AV=true ;; --no-waf) CORAZA=false ;; --selfsigned) USE_SELFSIGNED=true ;; --repair|--retry) REPAIR=true; SKIP_PANEL_CHECK=true; SKIP_APT_UPDATE=true ;; -h|--help) show_help; exit 0 ;; *) echo "Unknown option: $1"; show_help; exit 1 ;; esac shift done } check_requirements() { [[ -n "${SKIP_REQUIREMENTS:-}" ]] && return [[ "$(id -u)" == "0" ]] || die 1 "You must be root to run this script." [[ "$(uname)" != "Darwin" ]] || die 1 "macOS is not supported." if [[ -f /.dockerenv || -f /run/.containerenv ]] || { tr '\0' '\n' < /proc/1/environ | grep -qi '^container='; }; then die 1 "Running inside a container is not supported." fi local ram_mb ram_mb=$(( $(grep MemTotal /proc/meminfo | awk '{print $2}') / 1024 )) disk_mb=$(( $(df / --output=avail | tail -1) / 1024 )) (( ram_mb >= 1024 )) || die 1 "At least 1 GB RAM required (detected: ${ram_mb} MB)." (( disk_mb >= 5120 )) || die 1 "At least 5 GB free disk space required (detected: ${disk_mb} MB)." } detect_installed_panels() { [[ -n "${SKIP_PANEL_CHECK:-}" ]] && return declare -A panels=([/usr/local/admin/]="OpenPanel" [/usr/local/cpanel/whostmgr]="cPanel WHM" [/opt/psa/version]="Plesk" [/usr/local/psa/version]="Plesk" [/usr/local/CyberPanel]="CyberPanel" [/usr/local/directadmin]="DirectAdmin" [/usr/local/mgr5]="ispmanager" [/usr/local/cwpsrv]="CentOS Web Panel (CWP)" [/usr/local/vesta]="VestaCP" [/usr/local/hestia]="HestiaCP" [/usr/local/httpd]="Apache WebServer" [/usr/local/apache2]="Apache WebServer" [/usr/sbin/httpd]="Apache WebServer" [/sbin/httpd]="Apache WebServer" [/usr/lib/nginx]="Nginx WebServer") for path in "${!panels[@]}"; do [[ -e "$path" ]] || continue local name="${panels[$path]}" if [[ "$name" == "OpenPanel" ]]; then die 1 "OpenPanel is already installed. To update, run: opencli update --force" elif [[ "$name" == *WebServer* ]]; then die 1 "$name is already installed. OpenPanel requires a clean server with no web servers." else die 1 "$name is installed. OpenPanel requires a clean server with no control panels." fi done ok "No conflicting panels or web servers found." } detect_os_and_package_manager() { [[ -f /etc/os-release ]] || die 1 "Cannot detect OS: /etc/os-release not found." # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID,,}" OS_VERSION_ID="${VERSION_ID:-}" OS_CODENAME="${VERSION_CODENAME:-}" OS_NAME="${NAME:-}" export OS_ID OS_VERSION_ID OS_CODENAME OS_NAME case "$OS_ID" in ubuntu|debian) PACKAGE_MANAGER="apt-get" ;; fedora|rocky|almalinux|alma|openeuler) PACKAGE_MANAGER="dnf" ;; centos) PACKAGE_MANAGER="yum" ;; *) die 1 "Unsupported OS: $OS_ID" ;; esac case "$(uname -m)" in x86_64|amd64) architecture="x86_64" ;; aarch64|arm64) architecture="aarch64" ;; *) architecture="$(uname -m)" ;; esac } get_server_ipv4() { local ip ip=$(curl --silent --max-time 2 -4 "https://ip.openpanel.com" || curl --silent --max-time 2 -4 "https://ifconfig.me/ip") [[ -z "$ip" ]] && ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n1) [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || warn "Could not determine a valid public IPv4 address." SERVER_IPV4_ADDRESS="$ip" } set_panel_version() { if [[ "$CUSTOM_VERSION" == false ]]; then local response response=$(curl -4 -s "https://api.openpanel.com/v2/statistics/" || true) PANEL_VERSION=$(echo "$response" | grep -oP '"latest_version":"\K[^"]+' || true) [[ "$PANEL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || PANEL_VERSION="$DEFAULT_PANEL_VERSION" PANEL_VERSION="$DEFAULT_PANEL_VERSION" fi } print_header() { line echo -e " ____ _____ _ " echo -e " / __ \ | __ \ | | " echo -e " | | | | _ __ ___ _ __ | |__) | __ _ _ __ ___ | | " echo -e " | | | || '_ \ / _ \| '_ \ | ___/ / _\" || '_ \ / _ \| | " echo -e " | |__| || |_) || __/| | | | | | | (_| || | | || __/| | " echo -e " \____/ | .__/ \___||_| |_| |_| \__,_||_| |_| \___||_| " echo -e " | | " echo -e " |_| version: ${GREEN}$PANEL_VERSION${RESET} " line echo -e " OS: ${GREEN}${OS_NAME^^} ${OS_VERSION_ID}${RESET} | Arch: ${GREEN}${architecture^^}${RESET} | IP: ${GREEN}${SERVER_IPV4_ADDRESS}${RESET} | Engine: ${GREEN}${CONTAINER_ENGINE^^}${RESET} | Pkg: ${GREEN}${PACKAGE_MANAGER^^}${RESET}" line } update_package_manager() { [[ "$SKIP_APT_UPDATE" == true ]] && return echo "Updating $PACKAGE_MANAGER..." case "$PACKAGE_MANAGER" in apt-get) run apt-get update -y ;; yum|dnf) run $PACKAGE_MANAGER makecache ;; # metadata only esac } pkg_install_with_retry() { local pkg=$1 pkg_installed "$pkg" && { echo -e "${GREEN}$pkg already installed, skipping.${RESET}"; return; } echo -e "Installing ${GREEN}${pkg}${RESET}..." $PACKAGE_MANAGER install -y "$pkg" >/dev/null 2>&1 && return case "$pkg" in podman-compose) $PACKAGE_MANAGER install -y install podman-compose >/dev/null 2>&1; command -v podman-compose &>/dev/null && return ;; linux-image-amd64) $PACKAGE_MANAGER install -y linux-image >/dev/null 2>&1 && return ;; dbus-user-session) $PACKAGE_MANAGER install -y dbus >/dev/null 2>&1 && return ;; uidmap) $PACKAGE_MANAGER install -y shadow-utils >/dev/null 2>&1 && return ;; iptables) $PACKAGE_MANAGER install -y iptables-nft >/dev/null 2>&1 && return ;; netavark|aardvark-dns|crun) warn "Could not install $pkg — podman may fall back to CNI/runc."; return ;; quota|systemd-container|slirp4netns|fuse-overlayfs|passt) warn "Could not install $pkg — you may need to install it manually."; return ;; esac local attempt=1 max=10 delay=5 until $PACKAGE_MANAGER install -y "$pkg" >/dev/null 2>&1; do (( attempt++ )) (( attempt > max )) && die 1 "Failed to install $pkg after $max attempts." echo "Retry $attempt/$max for $pkg in ${delay}s..." sleep $delay done } install_pkgs_batch() { local missing=() pkg for pkg in "$@"; do if pkg_installed "$pkg"; then echo -e "${GREEN}$pkg already installed, skipping.${RESET}" else missing+=("$pkg") fi done (( ${#missing[@]} )) || { ok "All required packages already present."; return; } echo -e "Installing ${GREEN}${#missing[@]}${RESET} packages in one transaction..." local opts=() [[ "$PACKAGE_MANAGER" =~ ^(dnf|yum)$ ]] && opts=(--setopt=install_weak_deps=False) $PACKAGE_MANAGER install -y "${opts[@]}" "${missing[@]}" >/dev/null 2>&1 || warn "Batch install incomplete — retrying the remainder individually." _build_pkg_cache for pkg in "${missing[@]}"; do pkg_installed "$pkg" || pkg_install_with_retry "$pkg" done } wait_for_pkg_lock() { local max_wait=300 waited=0 while (( waited < max_wait )); do if ! fuser /var/lib/dpkg/lock-frontend &>/dev/null 2>&1 && ! pgrep -x 'dnf|yum|rpm' &>/dev/null; then return 0 fi echo "Package manager busy. Waiting..." sleep 5 (( waited += 5 )) done die 1 "Timeout waiting for package manager lock." } install_packages() { echo "Installing required packages..." local packages=() # NOTE: no Docker repos needed — podman ships in distro repos on all supported OS. # No iptables-legacy / EL10 reboot dance either: netavark works with nftables. case "$PACKAGE_MANAGER" in apt-get) local kernel_pkg="linux-image-amd64" [[ "$OS_ID" == "ubuntu" ]] && kernel_pkg="linux-generic" [[ -f /etc/needrestart/needrestart.conf ]] && run sed -i "s/#\$nrconf{restart} = 'i';/\$nrconf{restart} = 'a';/g" /etc/needrestart/needrestart.conf run $PACKAGE_MANAGER -qq install -y apt-transport-https ca-certificates echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80-retries run update-ca-certificates packages=(curl openssl cron git tar dbus-user-session systemd dbus systemd-container quota uidmap iptables podman podman-compose crun netavark aardvark-dns slirp4netns passt fuse-overlayfs "$kernel_pkg" default-mysql-client sqlite3) ;; yum) run yum install -y dnf-plugins-core yum-utils epel-release packages=(curl openssl cronie git tar dbus-user-session systemd dbus systemd-container quota uidmap iptables podman podman-compose crun netavark aardvark-dns slirp4netns passt fuse-overlayfs mariadb sqlite3) ;; dnf) if [[ "$OS_ID" == "openeuler" ]]; then run dnf install -y dnf-plugins-core yum-utils perl gcc tar packages=(git tar curl openssl ncurses wget cronie systemd dbus systemd-container quota shadow-utils podman podman-compose crun netavark aardvark-dns passt slirp4netns fuse-overlayfs mariadb sqlite perl-Math-BigInt) wait_for_pkg_lock install_pkgs_batch "${packages[@]}" return fi run dnf install -y yum-utils epel-release perl gcc if [[ -f /etc/fedora-release ]]; then packages=(git tar openssl wget dbus-user-session systemd dbus systemd-container quota uidmap podman podman-compose crun netavark aardvark-dns slirp4netns passt fuse-overlayfs mysql sqlite perl-Math-BigInt) else packages=(git tar openssl ncurses wget systemd dbus systemd-container quota shadow-utils podman podman-compose crun netavark aardvark-dns slirp4netns passt fuse-overlayfs mariadb sqlite perl-Math-BigInt) fi ;; esac wait_for_pkg_lock install_pkgs_batch "${packages[@]}" } clone_repos() { echo "Downloading OpenPanel project repositories from Github..." [[ "$REPAIR" == true ]] && rm -rf "$ETC_DIR" /usr/local/opencli /usr/local/opencli /usr/local/admin/ local branch="110" [[ "$architecture" == "aarch64" ]] && branch="armcpu" # openpanel-configuration echo "Downloading openpanel-configuration to $ETC_DIR" local tmp_extract="/tmp/openpanel-configuration-extract" rm -rf "$tmp_extract" mkdir -p "$tmp_extract" curl -sSL https://github.com/stefanpejcic/openpanel-configuration/archive/refs/heads/main.tar.gz -o /tmp/main.tar.gz && tar -xzf /tmp/main.tar.gz -C "$tmp_extract" --strip-components=1 mkdir -p "$ETC_DIR" cp -a "${tmp_extract}/." "$ETC_DIR" rm -rf /tmp/main.tar.gz "$tmp_extract" [[ -f "$CONFIG_FILE" ]] || die 1 "Config file ${CONFIG_FILE} is missing after downloading configuration from Github." # openadmin local admin_binary="openadmin-amd64" [[ "$architecture" == "aarch64" ]] && admin_binary="openadmin-arm64" echo "Downloading openadmin to /usr/local/admin/$admin_binary" mkdir -p /usr/local/admin/ git clone --branch main --single-branch https://github.com/stefanpejcic/openadmin.git /usr/local/admin >/dev/null 2>&1 curl -sSL "https://github.com/stefanpejcic/openadmin/releases/download/${PANEL_VERSION%%-beta}/$admin_binary" -o "/usr/local/admin/$admin_binary" [[ -f "/usr/local/admin/$admin_binary" ]] || die 1 "Failed to download OpenAdmin binary ${admin_binary} from Github." chmod +x "/usr/local/admin/$admin_binary" sed -i "s|^ExecStart=.*|ExecStart=/usr/local/admin/${admin_binary}|" "/etc/openpanel/openadmin/service/openadmin.service" # opencli echo "Downloading opencli commands to /usr/local/opencli" git clone --branch podman --single-branch https://github.com/stefanpejcic/opencli.git /usr/local/opencli >/dev/null 2>&1 } download_config() { [[ -f "$CONFIG_FILE" ]] || die 1 "Config file ${CONFIG_FILE} is missing." ok "configuration files ready." } setup_opencli() { echo "Setting up opencli..." chmod +x -R /usr/local/opencli ln -sf /usr/local/opencli/opencli /usr/local/bin/opencli export PATH="/usr/bin:$PATH" [[ -x /usr/local/bin/opencli ]] && ok "opencli commands are executable." || die 1 "opencli setup failed." } install_openadmin() { echo "Setting up OpenAdmin..." local dir="/usr/local/admin/" mkdir -p "$dir" if [[ "$ADMIN_PORT" != 2087 ]]; then sed -i "/# START HOSTNAME DOMAIN #/,/# END HOSTNAME DOMAIN #/ s/\(reverse_proxy localhost:\)[0-9]\+/\1$ADMIN_PORT/" "${ETC_DIR}caddy/Caddyfile" sed -i "/redir @openadmin/s/:[0-9]\+/:$ADMIN_PORT/g" "${ETC_DIR}caddy/redirects.conf" sed -i "/# openadmin/,/# roundcube/ s/:[0-9]\+/:$ADMIN_PORT/g" "${ETC_DIR}nginx/vhosts/openpanel_proxy.conf" fi cd "$dir" || die 1 "Failed to open $dir" for f in "${ETC_DIR}openadmin/secret.key" "${ETC_DIR}openpanel/secret.key"; do [[ -f "$f" ]] || { openssl rand -hex 32 > "$f"; chmod 600 "$f"; } done cp "${ETC_DIR}openadmin/service/openadmin.service" "${SERVICES_DIR}admin.service" run systemctl daemon-reload run systemctl enable --now admin local waited=0 until ss -tuln | grep -q ":$ADMIN_PORT" || (( waited >= 30 )); do sleep 1; (( waited++ )) done ss -tuln | grep -q ":$ADMIN_PORT" && ok "OpenAdmin is running on port $ADMIN_PORT." || die 1 "OpenAdmin is not listening on port $ADMIN_PORT." } setup_modprobe() { echo "Editing loadable kernel modules (drivers) from the Linux kernel..." ln -sf "${ETC_DIR}docker/modprobe.txt" /etc/modules-load.d/podman.conf modprobe ip_tables modprobe iptable_filter modprobe iptable_nat modprobe br_netfilter } fix_selinux_storage_labels() { # container-selinux's stock fcontext rules already cover /var/lib/containers command -v getenforce &>/dev/null || return 0 [[ "$(getenforce)" == "Disabled" ]] && return 0 command -v restorecon &>/dev/null || return 0 run restorecon -RF /var/lib/containers/storage /run/containers/storage "$SHARED_STORE" } podman_docker_alias() { # https://feldspaten.org/2021/07/16/podman-graph-driver-overwritten/ # Some VPS providers clone "fresh" servers from a template/golden image # that already contains podman storage state (sometimes with a blank # graph driver recorded) from when the template was built. That state # is inherited even on a first-ever run of this script, so reset # unconditionally rather than only under --repair. run podman system reset -f install -d -Z /var/lib/containers rm -rf ~/.local/share/containers/libpod # deterministic short-name resolution for all podman/docker calls mkdir -p /etc/containers/registries.conf.d echo 'unqualified-search-registries = ["docker.io"]' > /etc/containers/registries.conf.d/99-openpanel.conf if command -v pasta &>/dev/null; then mkdir -p /etc/containers/containers.conf.d printf '[network]\ndefault_rootless_network_cmd = "pasta"\n' > /etc/containers/containers.conf.d/99-openpanel-net.conf fi mkdir -p /var/lib/containers/storage /run/containers/storage "$SHARED_STORE" chmod -R o+rX "$SHARED_STORE" find "$SHARED_STORE" -name '*.lock' -exec chmod o+rw {} \; find /var/lib/containers/storage /run/containers/storage -mindepth 1 -delete 2>/dev/null || true cat > /etc/containers/storage.conf <> /etc/containers/containers.conf.d/99-openpanel-net.conf local podman_info_err if ! podman_info_err=$(podman info 2>&1 >/dev/null); then if [[ "$podman_info_err" == *"graph driver"*"does not match"* || "$podman_info_err" == *"database configuration mismatch"* ]]; then warn "Stale podman storage state detected — resetting and retrying..." run podman system reset -f rm -rf /var/lib/containers/storage/* /run/containers/storage/* 2>/dev/null || true podman info >/dev/null 2>&1 || die 1 "podman failed to initialize: $(tail -3 <<< "$podman_info_err")" else die 1 "podman failed to initialize: $(tail -3 <<< "$podman_info_err")" fi fi run systemctl enable --now podman.socket # now safe: storage.conf already in place command -v podman &>/dev/null && ok "Podman ... ready." || die 1 "Podman is not installed." } pull_sytem_images() { echo "Pulling shared images..." run podman --root "$SHARED_STORE" pull docker.io/library/alpine:latest || die 1 "Failed to pull alpine" [[ "$SKIP_DNS_SERVER" == true ]] || { run podman --root "$SHARED_STORE" pull docker.io/ubuntu/bind9:latest; } chmod -R o+rX "$SHARED_STORE" find "$SHARED_STORE" -name '*.lock' -exec chmod o+rw {} \; 2>/dev/null || true command -v restorecon &>/dev/null && restorecon -R "$SHARED_STORE" >/dev/null 2>&1 || true } prefetch_shared_images() { local compose_dir="${ETC_DIR}docker/compose/1.0" local compose_file="${compose_dir}/docker-compose.yml" [[ -f "$compose_file" ]] || { warn "Prefetch skipped — compose file not found: $compose_file"; return; } [[ -n "${SKIP_REQUIREMENTS:-}" ]] && return if (( disk_mb <= 20480 )); then echo "Root disk free is $(( disk_mb / 1024 ))GB (<=20GB) — skipping bulk shared-image prefetch." return fi echo "Root disk free is $(( disk_mb / 1024 ))GB — prefetching all shared images in background..." ( cd "$compose_dir" || exit 0 mapfile -t images < <(podman-compose -f "$compose_file" config 2>/dev/null | grep -oP '^\s*image:\s*\K\S+' | tr -d '"'\''' | sort -u) (( ${#images[@]} )) || mapfile -t images < <(grep -oP '^\s*image:\s*\K\S+' "$compose_file" | sed -E 's/\$\{[A-Za-z0-9_]+:-([^}]*)\}/\1/g' | grep -v '\${' | tr -d '"'\''' | sort -u) for img in "${images[@]}"; do [[ -z "$img" || "$img" == *'${'* ]] && continue if podman --root "$SHARED_STORE" pull "$img" >/dev/null 2>&1; then echo "[$(date +%T)] prefetch OK: $img" >> "$LOG_FILE" else echo "[$(date +%T)] prefetch FAIL: $img" >> "$LOG_FILE" fi done # newly pulled layers are root-owned 0600/0700 — rootless user podmans chmod -R o+rX "$SHARED_STORE" find "$SHARED_STORE" -name '*.lock' -exec chmod o+rw {} \; 2>/dev/null || true command -v restorecon &>/dev/null && restorecon -R "$SHARED_STORE" >/dev/null 2>&1 || true ) >/dev/null 2>&1 & disown } setup_compose() { echo "Setting up Podman Compose and system containers..." podman pull docker.io/library/mysql:latest >/dev/null 2>&1 & _MYSQL_PULL_PID=$! local test_output test_output=$(timeout 30 podman run --rm docker.io/library/alpine echo "Hello from Alpine!" 2>&1 || true) [[ "$test_output" == *"Hello from Alpine!"* ]] && ok "Podman alpine container ran successfully." || die 1 "Running alpine container failed, error: $test_output" echo "Setting up MariaDB..." local mysql_cnf="/etc/my.cnf" local root_pw; root_pw=$(openssl rand -hex 16) cd /root || die 1 "No read access to /root" rm -f "$mysql_cnf" .env cp "${ETC_DIR}docker/compose/docker-compose.yml" /root/docker-compose.yml cp "${ETC_DIR}docker/compose/.env" /root/.env cp "${ETC_DIR}mysql/initialize/1.1/plans.sql" /root/initialize.sql 2>/dev/null || true chmod +x "${ETC_DIR}mysql/scripts/dump.sh" "${ETC_DIR}openlitespeed/start.sh" sed -i "s/^VERSION=.*$/VERSION=\"$PANEL_VERSION\"/" /root/.env [[ "$USER_PORT" != 2083 ]] && { sed -i "s/^PORT=\"[^\"]*\"/PORT=\"$USER_PORT\"/" /root/.env sed -i "/redir @openpanel/s/:[0-9]\+/:$USER_PORT/g" "${ETC_DIR}caddy/redirects.conf" sed -i "/# openpanel/,/# openadmin/ s/:[0-9]\+/:$USER_PORT/g" "${ETC_DIR}nginx/vhosts/openpanel_proxy.conf" } sed -i "s|MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=${root_pw}|" /root/.env ln -s "${ETC_DIR}mysql/host_my.cnf" "$mysql_cnf" sed -i "s|password = .*|password = ${root_pw}|" "${ETC_DIR}mysql/host_my.cnf" sed -i "s|password = .*|password = ${root_pw}|" "${ETC_DIR}mysql/container_my.cnf" [[ "$OS_ID" == "almalinux" ]] && sed -i 's/mysql\/mysql-server/mysql/g' /root/docker-compose.yml if [[ "$OS_ID" == "debian" ]]; then run apt install -y apparmor apparmor-utils [[ "$OS_VERSION_ID" == "13" ]] && grep -q "skip-ssl" "$mysql_cnf" || echo "skip-ssl = true" >> "$mysql_cnf" fi [[ "$REPAIR" == true ]] && { run podman-compose -f /root/docker-compose.yml down run podman volume rm root_openadmin_mysql } wait "$_MYSQL_PULL_PID" 2>/dev/null || true run podman-compose -f /root/docker-compose.yml up -d openpanel_mysql local cid; cid=$(podman ps -q --filter "name=openpanel_mysql") [[ -n "$cid" ]] && ok "MySQL service started." || die 1 "MySQL container is not running." ln -sf / /hostfs 2>/dev/null || true } setup_bind() { [[ "$SKIP_DNS_SERVER" == true ]] && { echo "Skipping BIND (--skip-dns-server)."; return; } echo "Setting up BIND DNS..." install -d -m 755 /etc/bind cp -r "${ETC_DIR}bind9/"* /etc/bind/ echo "Pinning BIND9 to the server's real IP because Podman's aardvark-dns holds port 53 on each bridge gateway IP..." BIND_IP=$(hostname -I | awk '{print $1}') sed -i -E 's#^( *- *")([0-9.]+:)?(53:53/(tcp|udp)")#\1'"$BIND_IP"':\3#' /root/docker-compose.yml | grep 53:53 if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then local resolved_conf="/etc/systemd/resolved.conf" if [ -f "$resolved_conf" ]; then grep -q "^DNSStubListener=no" "$resolved_conf" || echo "DNSStubListener=no" >> "$resolved_conf" systemctl restart systemd-resolved fi fi local rndc_key="/etc/bind/rndc.key" if [[ ! -f "$rndc_key" ]]; then run timeout 90 podman run --rm -v /etc/bind/:/etc/bind/:Z --entrypoint=/bin/sh docker.io/ubuntu/bind9:latest -c 'rndc-confgen -a -A hmac-sha256 -b 256 -c /etc/bind/rndc.key' [[ -f "$rndc_key" ]] && ok "rndc.key generated." || warn "Could not generate rndc.key — DNS zone reloads may not work." fi find /etc/bind/ -type d -print0 | xargs -0 chmod 755 find /etc/bind/ -type f -print0 | xargs -0 chmod 644 } setup_firewall() { if [[ "$SKIP_FIREWALL" == true ]]; then echo "Skipping firewall (--skip-firewall)." sed -i 's/,csf//g' "${ETC_DIR}openadmin/config/notifications.ini" if command -v jq &>/dev/null; then jq 'map(select(.real_name != "csf" and .real_name != "lfd"))' /etc/openpanel/openadmin/config/services.json > /tmp/services.tmp.json && mv /tmp/services.tmp.json /etc/openpanel/openadmin/config/services.json fi return fi echo "Installing Sentinel Firewall..." wget --timeout=3 --tries=3 --inet4-only https://raw.githubusercontent.com/sentinelfirewall/sentinel/main/csf.tgz >/dev/null 2>&1 tar -xzf csf.tgz; rm csf.tgz ( cd csf && sh install.sh >/dev/null 2>&1 ) rm -rf csf if [[ "$PACKAGE_MANAGER" == "dnf" ]]; then run dnf install -y wget curl yum-utils #policycoreutils-python-utils run dnf install -y libwww-perl || run dnf install -y perl-libwww-perl [[ -f /etc/fedora-release ]] && run yum --allowerasing install perl -y else run apt-get install -y perl libwww-perl libgd-dev libgd-perl libgd-graph-perl fi if ! command -v iptables &>/dev/null; then pkg_install_with_retry iptables command -v iptables &>/dev/null || { warn "iptables not available — skipping Sentinel Firewall installation."; return; } fi cat >> "/usr/local/csf/bin/csfpre.sh" < container) if needed: iptables -I FORWARD -d 172.20.0.0/24 -m state --state ESTABLISHED,RELATED -j ACCEPT iptables -I FORWARD 1 -o podman1 -j ACCEPT iptables -I FORWARD 1 -i podman1 -j ACCEPT EOF cat >> "/usr/local/csf/bin/csfpost.sh" <<'EOF' #!/bin/bash # CSF flushes iptables on reload; netavark's container rules get wiped. podman network reload --all for iface in /sys/class/net/podman[0-9]*; do [ -e "$iface" ] || continue i="${iface##*/}" iptables -I INPUT -i "$i" -j ACCEPT iptables -I OUTPUT -o "$i" -j ACCEPT iptables -I FORWARD -i "$i" -j ACCEPT iptables -I FORWARD -o "$i" -j ACCEPT done EOF chmod +x /usr/local/csf/bin/csfpre.sh /usr/local/csf/bin/csfpost.sh # netavark bridge is podman0; cni-podman0 kept for CNI fallback sed -i -e 's/TESTING = "1"/TESTING = "0"/' -e 's/RESTRICT_SYSLOG = "0"/RESTRICT_SYSLOG = "3"/' -e 's/ETH_DEVICE_SKIP = ""/ETH_DEVICE_SKIP = "podman0,podman1,podman2,podman3"/' /etc/csf/csf.conf cp "${ETC_DIR}csf/csf.blocklists" /etc/csf/csf.blocklists local email; email=$(grep -E "^e-mail=" "$CONFIG_FILE" | cut -d= -f2 || true) [[ -n "$email" ]] && sed -i "s/LF_ALERT_TO = \"\"/LF_ALERT_TO = \"$email\"/" /etc/csf/csf.conf open_csf_port() { local type=$1 port=$2 local conf="/etc/csf/csf.conf" for dir in "$type" "${type/4/6}"; do grep -q "${dir} = .*${port}" "$conf" || sed -i "s/${dir} = \"\(.*\)\"/${dir} = \"\1,${port}\"/" "$conf" done } local ssh_port; ssh_port=$(grep -Po "(?<=Port[ =])\d+" /etc/ssh/sshd_config 2>/dev/null || echo 22) for p in 3306 465 "$USER_PORT" "$ADMIN_PORT"; do open_csf_port TCP_OUT "$p"; done for p in 22 53 80 443 2053 8888 "$USER_PORT" "$ADMIN_PORT" "32768:60999" 21 "21000:21010" "$ssh_port"; do open_csf_port TCP_IN "$p" done run csf -r run systemctl enable csf run systemctl restart csf run systemctl restart podman.socket install -m 755 /dev/null /usr/sbin/sendmail command -v csf &>/dev/null && ok "Sentinel Firewall installed." || fail "Sentinel Firewall not installed properly." } configure_caddy_extras() { sed -i "s/example\.net/$SERVER_IPV4_ADDRESS/g" "${ETC_DIR}caddy/redirects.conf" 2>/dev/null || true grep -qE '^127\.0\.0\.1\s+localhost' /etc/hosts || echo "127.0.0.1 localhost" >> /etc/hosts } set_hostname() { if [ "$SET_HOSTNAME_NOW" == false ]; then local local_ips local_ips=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}') if echo "$local_ips" | grep -qw "$SERVER_IPV4_ADDRESS"; then echo "Setting shortlived SSL for IP address $SERVER_IPV4_ADDRESS" SET_HOSTNAME_NOW=true new_hostname="$SERVER_IPV4_ADDRESS" fi fi [[ "$SET_HOSTNAME_NOW" != true ]] && return if [[ -n "$separate_panel_domain" && "$separate_panel_domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then if [[ "$separate_panel_domain" != "$new_hostname" ]]; then cat >> "${ETC_DIR}caddy/Caddyfile" <&1 | grep -Ei 'tls|acme|certificate|renew|obtain|challenge' | tail -1)" } configure_waf() { opencli waf "$([[ "$CORAZA" == true ]] && printf '%s' enable || printf '%s' disable)" > /dev/null 2>&1 } setup_redis() { install -d -m 777 /tmp/redis; } enable_disk_quotas() { echo "Enabling disk quotas..." local fstab="/etc/fstab" if ! grep -E '^\S+\s+/\s+' "$fstab" | grep -q "usrquota"; then sed -i -E '/\s+\/\s+/s/(\S+)(\s+\/\s+\S+\s+\S+)(\s+[0-9]+\s+[0-9]+)$/\1\2,usrquota,grpquota\3/' "$fstab" fi run systemctl daemon-reload run quotaoff -a run mount -o remount,usrquota,grpquota / run quotacheck -cum / -f run quotaon -a repquota -u / > "/tmp/repquota" 2>/dev/null && ok "Disk quotas enabled." || fail "Quota check failed." } set_container_cpu_limits() { mkdir -p /etc/systemd/system/user@.service.d echo -e "[Service]\nDelegate=cpu cpuset io memory pids" > /etc/systemd/system/user@.service.d/delegate.conf run systemctl daemon-reload } configure_premium() { [[ "$SET_PREMIUM" != true ]] && return LICENSE="Enterprise" timeout 300 opencli license "$license_key" } configure_imunifyav() { [[ "$IMUNIFY_AV" == true ]] && run opencli imunify install && run opencli imunify start } configure_ssh() { ln -sf "${ETC_DIR}ssh/admin_welcome.sh" /etc/profile.d/welcome.sh chmod +x /etc/profile.d/welcome.sh [[ -f /etc/ssh/sshd_config ]] || return sed -i "s/[#]LoginGraceTime [[:digit:]]m/LoginGraceTime 1m/" /etc/ssh/sshd_config [[ -f /etc/pam.d/sshd ]] && sed -i '/pam_motd\.so/s/^/#/' /etc/pam.d/sshd if [[ "$PACKAGE_MANAGER" == "apt-get" ]]; then grep -q "^DebianBanner no" /etc/ssh/sshd_config || echo "DebianBanner no" >> /etc/ssh/sshd_config fi run systemctl restart sshd 2>/dev/null || run systemctl restart ssh ok "SSH configured." } setup_cron() { install -m 600 -o root -g root "${ETC_DIR}cron" /etc/cron.d/openpanel [[ "$PACKAGE_MANAGER" =~ ^(dnf|yum)$ ]] && { run restorecon -R /etc/cron.d; run systemctl restart crond; } ok "Cron configured." } setup_logrotate() { opencli server-logrotate; } setup_log_dirs() { local log_dir="/var/log/openpanel" install -d -m 755 "$log_dir" "$log_dir/user" "$log_dir/admin" } setup_swap() { [[ -n "$(swapon -s)" ]] && { echo "Swap already exists, skipping."; return; } create_swap() { fallocate -l "${SWAP_FILE}G" /swapfile chmod 600 /swapfile; mkswap /swapfile; swapon /swapfile echo "/swapfile none swap sw 0 0" >> /etc/fstab ok "Created ${SWAP_FILE}G swap file." } local ram_gb ram_gb=$(awk '/MemTotal/{printf "%.0f", $2/1024/1024}' /proc/meminfo) auto_size() { if (( ram_gb <= 2 )); then SWAP_FILE=$(( ram_gb * 2 )) elif (( ram_gb <= 4 )); then SWAP_FILE=$ram_gb elif (( ram_gb <= 8 )); then SWAP_FILE=4 elif (( ram_gb <= 32 )); then SWAP_FILE=8 else echo "RAM is ${ram_gb}GB — skipping swap creation."; return 1 fi } if [[ "$SETUP_SWAP_ANYWAY" == true ]] || (( ram_gb <= 32 )); then if ! [[ "$SWAP_FILE" =~ ^[0-9]+$ && "$SWAP_FILE" -ge 1 && "$SWAP_FILE" -le 10 ]]; then warn "Invalid swap size '$SWAP_FILE'. Auto-sizing based on RAM." auto_size || return fi create_swap else echo "RAM is ${ram_gb}GB — skipping swap creation." fi } hetzner_fix() { [[ -f /etc/hetzner-build ]] || return echo "Hetzner detected — adding Cloudflare DNS resolvers..." mv /etc/resolv.conf /etc/resolv.conf.bak printf "nameserver 1.1.1.1\nnameserver 1.0.0.1\n" > /etc/resolv.conf command -v podman &>/dev/null || run $PACKAGE_MANAGER install -y podman } clean_cache() { run $PACKAGE_MANAGER clean all 2>/dev/null || true; } verify_license() { curl -4 -s -X POST -H "Content-Type: application/json" -d "{\"hostname\":\"$(hostname)\",\"public_ip\":\"${SERVER_IPV4_ADDRESS}\"}" https://api.openpanel.com/license/index.php >/dev/null 2>&1 & } start_system_containers() { podman pull docker.io/openpanel/openpanel:$PANEL_VERSION >/dev/null 2>&1 & } run_housekeeping_parallel() { echo "Running housekeeping steps.." declare -A _jobs=() _run_bg() { local name=$1 fn=$2 ( $fn ) >"/tmp/hk_${name}.log" 2>&1 & _jobs["$name"]=$! } _run_bg "cron" setup_cron _run_bg "logrotate" setup_logrotate _run_bg "ssh" configure_ssh _run_bg "log_dirs" setup_log_dirs _run_bg "swap" setup_swap _run_bg "waf" configure_waf _run_bg "redis" setup_redis _run_bg "quotas" enable_disk_quotas _run_bg "cpu_limits" set_container_cpu_limits _run_bg "modprobe" setup_modprobe local failed=0 for name in "${!_jobs[@]}"; do if ! wait "${_jobs[$name]}"; then fail "Housekeeping step '$name' failed:" cat "/tmp/hk_${name}.log" failed=1 fi done [[ "$failed" -eq 0 ]] && ok "All housekeeping steps completed." || warn "Some housekeeping steps failed — see log above." } create_admin_account() { if [[ "$SET_ADMIN_USERNAME" == true ]]; then new_username="$custom_username" else # shellcheck disable=SC1091,SC2154 wget --inet4-only --timeout=3 --tries=2 -q -O /tmp/generate.sh https://raw.githubusercontent.com/stefanpejcic/random-username-generator/refs/heads/main/generator.sh 2>/dev/null && source /tmp/generate.sh && new_username="$random_name" || new_username="admin" fi if [[ "$SET_ADMIN_PASSWORD" == true && "$custom_password" =~ ^[A-Za-z0-9]{5,30}$ ]]; then new_password="$custom_password" else [[ "$SET_ADMIN_PASSWORD" == true ]] && warn "Provided password is invalid — generating a secure one." new_password=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16) fi sqlite3 "${ETC_DIR}openadmin/users.db" "CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', is_active BOOLEAN DEFAULT 1 NOT NULL, totp_secret TEXT, totp_enabled BOOLEAN DEFAULT 0 NOT NULL);" 2>/dev/null || true opencli admin new "$new_username" "$new_password" --super >/dev/null 2>&1 || true local count; count=$(sqlite3 "${ETC_DIR}openadmin/users.db" "SELECT COUNT(*) FROM user WHERE username = '$new_username';" 2>/dev/null || echo 0) if [[ "$count" -eq 0 ]]; then die 1 "Failed to create Admin account - is sqlite3 installed?" fi display_logins systemctl restart admin send_email_if_configured } extra_step_for_podman() { systemctl reset-failed podman.socket 2>/dev/null || true systemctl restart podman.socket 2>/dev/null || true } display_logins() { exec > /dev/tty 2>&1 echo "" printf "${GREEN}OpenPanel %s %s installed successfully ${RESET}in %dm %ds\n" "$LICENSE" "$PANEL_VERSION" "$minutes" "$seconds" line opencli admin echo -e " Username: ${GREEN}${new_username}${RESET}" echo -e " Password: ${GREEN}${new_password}${RESET}" line exec > >(tee -a "$LOG_FILE") 2>&1 } send_email_if_configured() { [[ "$SEND_EMAIL_AFTER_INSTALL" != true ]] && return [[ "$EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] || { warn "Invalid email '$EMAIL'. Skipping notification."; return; } opencli config update email "$EMAIL" local token; token=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 64) sed -i "s|^mail_security_token=.*|mail_security_token=$token|" "$CONFIG_FILE" local protocol="http" domain="127.0.0.1" if [[ "$SET_HOSTNAME_NOW" == true && "$new_hostname" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then local ssl_dir="${ETC_DIR}caddy/ssl" if [[ -f "$ssl_dir/acme-v02.api.letsencrypt.org-directory/$new_hostname/$new_hostname.key" || -f "$ssl_dir/custom/$new_hostname/$new_hostname.key" ]]; then protocol="https"; domain="$new_hostname" fi fi curl -4 -k -X POST "$protocol://$domain:$ADMIN_PORT/send_email" -F "transient=$token" -F "recipient=$EMAIL" -F "subject=OpenPanel successfully installed" -F "body=OpenAdmin URL: http://$(hostname):$ADMIN_PORT/ | username: $new_username | password: $new_password" --max-time 15 >/dev/null 2>&1 || warn "Failed to send email notification." } run_post_install() { [[ -z "$post_install_path" ]] && return echo "Running post-install script: $post_install_path" if [[ "$post_install_path" =~ ^https?:// ]]; then local tmp; tmp=$(mktemp) wget -q -O "$tmp" "$post_install_path" || { warn "Failed to download post-install script."; return; } chmod +x "$tmp"; bash "$tmp"; rm -f "$tmp" else bash "$post_install_path" fi } support_message() { line cat </dev/null; then curl -4 --max-time 5 -s "$url" -o progress_bar.sh >/dev/null 2>&1 elif command -v wget &>/dev/null; then wget --timeout=5 --tries=3 --inet4-only "$url" -O progress_bar.sh >/dev/null 2>&1 else echo "Neither wget nor curl found."; exit 1 fi [[ -f progress_bar.sh ]] || { echo "Failed to download progress_bar.sh"; exit 1; } # shellcheck disable=SC1091 source progress_bar.sh } STEPS=( update_package_manager install_packages podman_docker_alias pull_sytem_images hetzner_fix clone_repos download_config prefetch_shared_images setup_opencli install_openadmin setup_compose setup_bind setup_firewall configure_premium configure_caddy_extras set_hostname generate_ssl configure_imunifyav run_housekeeping_parallel clean_cache verify_license start_system_containers ) run_installation() { enable_trapping setup_scroll_area local total=${#STEPS[@]} current=0 for step in "${STEPS[@]}"; do s=$(date +%s); $step; e=$(date +%s) echo "[TIMING] $step: $((e-s))s" >> "$LOG_FILE" (( current++ )); draw_progress_bar $(( current * 100 / total )) done destroy_scroll_area } # Main ( flock -n 200 || { echo "Another install is already running."; exit 1; } detect_os_and_package_manager parse_args "$@" [[ -r /root && -w /root ]] || { echo "No read/write access to /root."; exit 1; } get_server_ipv4 set_panel_version print_header check_requirements detect_installed_panels echo -e "Starting OpenPanel installation process..." start=$(date +%s) setup_progress_bar _build_pkg_cache run_installation duration=$(($(date +%s) - start)) minutes=$((duration / 60)) seconds=$((duration % 60)) support_message create_admin_account extra_step_for_podman run_post_install ) 200>/root/openpanel_install.lock rm -f /root/openpanel_install.lock