#!/usr/bin/env bash # ============================================================================ # cPanel Bootstrap para AlmaLinux 9 — script único # ---------------------------------------------------------------------------- # COMO USAR (instalação remota, recomendado): # curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL \ # https://get.rapidcloud.com.br/install.sh | bash # (ou, para revisar o arquivo completo antes da execução: # curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL \ # https://get.rapidcloud.com.br/cpanel-bootstrap.sh \ # -o cpanel-bootstrap.sh && bash cpanel-bootstrap.sh ) # IMPORTANTE: `curl URL | bash` direto no script principal NÃO é suportado # (ele precisa existir em disco para se auto-instalar para a fase 2). # # OVERRIDES SEM EDITAR O ARQUIVO: toda variável da CONFIGURAÇÃO PADRÃO # aceita override por variável de ambiente, inclusive através do wrapper, ex.: # SSH_PORT=2222 ADMIN_EMAIL=voce@dominio.com bash cpanel-bootstrap.sh # Os valores validados na fase 1 são persistidos em runtime.env e valem # também para as fases 2/3 (que rodam via systemd, sem o seu ambiente). # (Override vazio só funciona nas opções que o documentam explicitamente, # como AUTOSSL_PROVIDER=""; nas demais, edite o arquivo para zerar um default.) # # FLUXO MANUAL (alternativa): suba via scp/nano, revise a CONFIGURAÇÃO # PADRÃO e execute como root: bash cpanel-bootstrap.sh # → informe o hostname e confirme console/IP estático quando solicitado # → o servidor reinicia sozinho e retoma a instalação # → acompanhe com: tail -f /var/log/cpanel-bootstrap/phase2.log # ou a qualquer momento: cpanel-bootstrap.sh --status # # FLUXO: # Fase 1 (pré-reboot) → reboot automático → Fase 2 (via timer systemd) # → resumo em /root/cpanel-bootstrap-summary.txt → reboot manual # → Fase 3 (verificação automática pós-reboot + notificação) # # Se uma configuração retomável da fase 2 falhar, o timer re-tenta a cada # ~15 min (até MAX_PHASE2_ATTEMPTS). Instalação parcial do cPanel é exceção: # suspende imediatamente e exige snapshot/SO limpo; nunca reexecuta latest. # Para falhas retomáveis, retome manualmente com: # systemctl start cpanel-bootstrap.service # # SEM LICENÇA cPANEL? Sem problema (fluxo normal de entrega): a instalação # completa normalmente e os passos que dependem da API (backup, AutoSSL, # cPHulk) são aplicados AUTOMATICAMENTE quando a licença ativar (timer a # cada 30 min) — ou na hora, com: cpanel-bootstrap.sh --finish # ============================================================================ VERSION="2.3.0" set -Eeuo pipefail # ============================================================================ # CONFIGURAÇÃO PADRÃO # ============================================================================ # FQDN obrigatório do servidor. Se ficar vazio, o script perguntará na fase 1. HOSTNAME_FQDN="${HOSTNAME_FQDN:-}" # Porta SSH final (após instalação do cPanel e configuração do CSF) SSH_PORT="${SSH_PORT:-1140}" # Timezone do sistema e do PHP TIMEZONE="${TIMEZONE:-America/Sao_Paulo}" # Tier do cPanel: stable | release | lts | current | edge CPANEL_TIER="${CPANEL_TIER:-stable}" # E-mail do administrador. Usado para: alertas críticos do CSF/LFD, e-mail de # contato do WHM (CONTACTEMAIL), alias de root e notificações do bootstrap. ADMIN_EMAIL="${ADMIN_EMAIL:-root@localhost}" # Alertas críticos do CSF/LFD por e-mail: 1 = ligados, 0 = silenciados CSF_CRITICAL_EMAIL_ALERTS="${CSF_CRITICAL_EMAIL_ALERTS:-1}" # Se ficar 0, o script perguntará na fase 1 antes de mudar SSH/firewall CONFIRM_CONSOLE_ACCESS="${CONFIRM_CONSOLE_ACCESS:-0}" # Se ficar 0, o script perguntará na fase 1 antes de validar hostname/licença CONFIRM_STATIC_PUBLIC_IP="${CONFIRM_STATIC_PUBLIC_IP:-0}" # O cPanel suporta instalar antes de o DNS do hostname estar publicado: nesse # caso ele pode usar temporariamente um hostname *.cprapid.com. O bootstrap # guarda o FQDN desejado e o reconcilia automaticamente quando o registro A # passar a apontar para o IP público. # 1 = DNS ausente/divergente vira pendência não fatal (padrão). # 0 = modo estrito; bloqueia a instalação enquanto o registro A não estiver OK. # SERVFAIL/REFUSED/timeout do resolvedor continuam bloqueantes nos dois modos. ALLOW_DNS_MISMATCH="${ALLOW_DNS_MISMATCH:-1}" # 0 = usa somente SSH_PORT. 1 = mantém a porta 22 aberta junto com SSH_PORT. # O default seguro é 1: feche a 22 somente depois de validar um login EXTERNO # real na nova porta. KEEP_SSH_PORT_22_OPEN=0 é uma decisão explícita do operador. KEEP_SSH_PORT_22_OPEN="${KEEP_SSH_PORT_22_OPEN:-1}" # Idioma padrão do WHM/cPanel/Webmail SERVER_LOCALE="${SERVER_LOCALE:-pt_br}" # Range de portas passivas do FTP (Pure-FTPd) — também liberado no CSF FTP_PASV_MIN="${FTP_PASV_MIN:-30000}" FTP_PASV_MAX="${FTP_PASV_MAX:-50000}" # Nível de TLS do Pure-FTPd: 0 = sem TLS, 1 = TLS opcional, # 2 = TLS obrigatório no controle (recomendado), 3 = TLS obrigatório em tudo FTP_TLS_LEVEL="${FTP_TLS_LEVEL:-2}" # Tamanho do filesystem /tmp gerado pelo cPanel — definido automaticamente # em runtime conforme o tamanho do disco /: # • disco < 100 GB → /tmp = 8 GB # • disco >= 100 GB → /tmp = 10 GB (teto aceito pelo cPanel) # Para forçar um valor, descomente e ajuste: # TMP_SIZE_MB="10240" # PHP-FPM max_children por pool. Vazio = automático conforme RAM PHP_FPM_MAX_CHILDREN="${PHP_FPM_MAX_CHILDREN:-}" # Defaults de PHP aplicados a TODAS as versões EA-PHP (e herdados por todas # as contas). Uploads PHP são bufferizados em /tmp (limitado a 8-10 GB pelo # securetmp), então valores gigantes de upload/post permitem que poucas # requisições simultâneas lotem o /tmp — que o MySQL também usa. Para casos # especiais, aumente por conta em cPanel → MultiPHP INI Editor. PHP_MEMORY_LIMIT="${PHP_MEMORY_LIMIT:-512M}" PHP_UPLOAD_MAX_FILESIZE="${PHP_UPLOAD_MAX_FILESIZE:-256M}" PHP_POST_MAX_SIZE="${PHP_POST_MAX_SIZE:-272M}" # Proteção SYN flood do CSF. A própria documentação do CSF recomenda ligar # APENAS durante um ataque real: o rate-limit é global (não por IP) e derruba # conexões legítimas em picos de tráfego. O sysctl tcp_syncookies=1 aplicado # pelo bootstrap já é a mitigação permanente correta. 1 = liga mesmo assim. CSF_SYNFLOOD="${CSF_SYNFLOOD:-0}" # Chave pública SSH do administrador (conteúdo completo de uma linha do # authorized_keys, ex: "ssh-ed25519 AAAA... admin@host"). Se preenchida, # é instalada em /root/.ssh/authorized_keys e o root passa a logar apenas # com chave (PermitRootLogin prohibit-password). ADMIN_SSH_PUBKEY="${ADMIN_SSH_PUBKEY:-}" # 1 = desativa autenticação por senha no SSH (exige ADMIN_SSH_PUBKEY acima) SSH_DISABLE_PASSWORD_AUTH="${SSH_DISABLE_PASSWORD_AUTH:-0}" # IPs de gestão adicionais para whitelist anti-lockout no CSF (csf.allow + # csf.ignore) e no cPHulk, separados por espaço. O IP da sessão SSH que # executar a fase 1 é adicionado automaticamente. ADMIN_ALLOW_IPS="${ADMIN_ALLOW_IPS:-}" # Notificações de progresso/falha do bootstrap (opcionais). No uso interativo, # o script pergunta pelo Incoming Webhook do Slack sem exibir a URL digitada. # Webhook — Slack (Incoming Webhook, https://hooks.slack.com/services/...), # Discord ou qualquer endpoint que aceite JSON {"text": "..."} NOTIFY_WEBHOOK_URL="${NOTIFY_WEBHOOK_URL:-}" # Telegram: token do bot e chat_id de destino NOTIFY_TELEGRAM_BOT_TOKEN="${NOTIFY_TELEGRAM_BOT_TOKEN:-}" NOTIFY_TELEGRAM_CHAT_ID="${NOTIFY_TELEGRAM_CHAT_ID:-}" # 1 = cria swapfile automaticamente se o servidor não tiver swap (recomendado # em cloud images, que costumam vir sem swap; evita OOM-killer no MySQL) SWAP_ENABLE="${SWAP_ENABLE:-1}" # 1 = grava tuning do MySQL/MariaDB dimensionado pela RAM (buffer pool etc.) MYSQL_TUNING="${MYSQL_TUNING:-1}" # Backup diário/semanal do cPanel (WHM → Backup Configuration) BACKUP_ENABLE="${BACKUP_ENABLE:-1}" BACKUP_DAILY_RETENTION="${BACKUP_DAILY_RETENTION:-3}" BACKUP_WEEKLY_ENABLE="${BACKUP_WEEKLY_ENABLE:-1}" BACKUP_WEEKLY_RETENTION="${BACKUP_WEEKLY_RETENTION:-2}" # Destino remoto SFTP opcional (deixe BACKUP_REMOTE_HOST vazio para só local) BACKUP_REMOTE_HOST="${BACKUP_REMOTE_HOST:-}" BACKUP_REMOTE_PORT="${BACKUP_REMOTE_PORT:-22}" BACKUP_REMOTE_USER="${BACKUP_REMOTE_USER:-root}" BACKUP_REMOTE_KEYFILE="${BACKUP_REMOTE_KEYFILE:-}" BACKUP_REMOTE_PATH="${BACKUP_REMOTE_PATH:-cpanel-backups}" # 1 = aplica tweaks de segurança de contas: DKIM/SPF por padrão em contas # novas, jailshell como shell padrão, força mínima de senha e compiladores # restritos a root SECURITY_TWEAKS="${SECURITY_TWEAKS:-1}" MIN_PW_STRENGTH="${MIN_PW_STRENGTH:-65}" # 1 = instala ModSecurity 2 + OWASP Core Rule Set via EA4 (WAF gerenciado # pelo WHM; regras atualizadas automaticamente) INSTALL_MODSECURITY_CRS="${INSTALL_MODSECURITY_CRS:-1}" # Provider do AutoSSL. "LetsEncrypt" recomendado; vazio = mantém o padrão # da cPanel e não emite certificado do hostname na fase 2 AUTOSSL_PROVIDER="${AUTOSSL_PROVIDER-LetsEncrypt}" # cPanel 134+ exclui novos domínios do AutoSSL por padrão. 1 restaura a # inclusão automática; ao usar 1, o provedor assume a responsabilidade de # informar seus clientes e obter concordância com os termos do Let's Encrypt. AUTOSSL_INCLUDE_NEW_DOMAINS="${AUTOSSL_INCLUDE_NEW_DOMAINS:-1}" # Nameservers padrão do WHM (opcional — deixe vazio para configurar depois) WHM_NS1="${WHM_NS1:-}" WHM_NS2="${WHM_NS2:-}" # Antivírus: imunifyav | clamav | none. O instalador oficial já instala # ImunifyAV; quando ClamAV/none é escolhido, usamos --skip-imunifyav para nunca # manter dois antivírus concorrentes no mesmo servidor. ANTIVIRUS="${ANTIVIRUS:-imunifyav}" # 1 = mantém o WordPress Toolkit instalado pelo `latest`; 0 = pula o plugin. WP_TOOLKIT_ENABLE="${WP_TOOLKIT_ENABLE:-0}" # Perfil EasyApache 4 completo, opcional, gerado/exportado de um servidor de # referência. Vazio = mantém o perfil padrão e aplica event/FPM na fase 2. EA4_PROFILE_PATH="${EA4_PROFILE_PATH:-}" # Extras opcionais (0 = não instala) NGINX_PROXY_ENABLE="${NGINX_PROXY_ENABLE:-0}" # ea-nginx + HTTP/2 REDIS_ENABLE="${REDIS_ENABLE:-0}" # Redis local com senha obrigatória REDIS_PASSWORD="${REDIS_PASSWORD:-}" # Pré-requisitos mínimos para continuar a instalação MIN_MEMORY_MB="${MIN_MEMORY_MB:-2048}" MIN_ROOT_FREE_MB="${MIN_ROOT_FREE_MB:-40960}" MIN_HOME_FREE_MB="${MIN_HOME_FREE_MB:-10240}" # Máximo de execuções automáticas da fase 2 (re-tentativas do timer após # falhas nas configurações retomáveis). O instalador `latest` nunca é # reexecutado automaticamente: download pode repetir, instalação não. # Ao exceder, o bootstrap se suspende e notifica. MAX_PHASE2_ATTEMPTS="${MAX_PHASE2_ATTEMPTS:-8}" # Limites contra processos presos. A fase 2 pode ser longa, mas nunca infinita. CPANEL_INSTALL_TIMEOUT="${CPANEL_INSTALL_TIMEOUT:-14400}" PHASE2_TIMEOUT="${PHASE2_TIMEOUT:-21600}" MAX_PHASE3_ATTEMPTS="${MAX_PHASE3_ATTEMPTS:-4}" # Máximo de execuções LICENCIADAS do --finish que podem falhar antes de o # watcher se suspender (finish.halt) e notificar. Execuções que apenas # aguardam licença/DNS não contam. 16 × 30 min ≈ 8 h de retentativas. MAX_FINISH_ATTEMPTS="${MAX_FINISH_ATTEMPTS:-16}" # 1 = exige destino remoto quando BACKUP_ENABLE=1. Útil no perfil de produção. BACKUP_REQUIRE_REMOTE="${BACKUP_REQUIRE_REMOTE:-0}" # 1 permite webhook HTTP em laboratório. Em produção, HTTPS é obrigatório. NOTIFY_ALLOW_HTTP="${NOTIFY_ALLOW_HTTP:-0}" # ============================================================================ # NÃO É NECESSÁRIO EDITAR NADA ABAIXO DESTA LINHA # ============================================================================ LOG_DIR="/var/log/cpanel-bootstrap" STATE_DIR="/var/lib/cpanel-bootstrap" STEP_DIR="/var/lib/cpanel-bootstrap/steps" INSTALL_PATH="/usr/local/sbin/cpanel-bootstrap.sh" SERVICE_PATH="/etc/systemd/system/cpanel-bootstrap.service" TIMER_PATH="/etc/systemd/system/cpanel-bootstrap.timer" VERIFY_SERVICE_PATH="/etc/systemd/system/cpanel-bootstrap-verify.service" VERIFY_TIMER_PATH="/etc/systemd/system/cpanel-bootstrap-verify.timer" LICENSE_SERVICE_PATH="/etc/systemd/system/cpanel-bootstrap-license.service" LICENSE_TIMER_PATH="/etc/systemd/system/cpanel-bootstrap-license.timer" GAP_FIREWALL_SERVICE_PATH="/etc/systemd/system/cpanel-bootstrap-firewall.service" GAP_FIREWALL_RULES_PATH="/etc/nftables/cpanel-bootstrap.nft" SUMMARY_FILE="/root/cpanel-bootstrap-summary.txt" PHASE3_REPORT="/root/cpanel-bootstrap-phase3-report.txt" HOSTNAME_CERT_STATUS_PATH="${STATE_DIR}/hostname-cert.status" HOSTNAME_CERT_ATTEMPT_PATH="${STATE_DIR}/hostname-cert.attempt" HOSTNAME_CERT_PENDING_MARKER="${STATE_DIR}/hostname-cert.pending" RUNTIME_CONFIG_PATH="${STATE_DIR}/runtime.env" CPANEL_INSTALL_SUCCESS_MARKER="${STATE_DIR}/cpanel-installer.done" CPANEL_INSTALL_STARTED_MARKER="${STATE_DIR}/cpanel-installer.started" CPANEL_INSTALL_FAILED_MARKER="${STATE_DIR}/cpanel-installer.failed" POSTLICENSE_SUCCESS_MARKER="${STATE_DIR}/postlicense.done" FINISH_PENDING_MARKER="${STATE_DIR}/finish.pending" HOSTNAME_RECONCILE_REQUIRED_MARKER="${STATE_DIR}/hostname-reconcile.required" HOSTNAME_RECONCILE_CHANGED_MARKER="${STATE_DIR}/hostname-reconcile.changed" EA4_INITIAL_PROFILE="/etc/cpanel_initial_install_ea4_profile.json" CPANEL_CONFIG="/var/cpanel/cpanel.config" CSF_CONF="/etc/csf/csf.conf" SERVER_PUBLIC_IP="" ADMIN_SRC_IP="" CURRENT_STEP="" CPANEL_HOSTNAME_DNS_STATUS="" CPANEL_HOSTNAME_DNS_IPV4="" HOSTNAME_CERT_RETRY_COOLDOWN=86400 CPANEL_MIN_TMP_SIZE_MB="512" CPANEL_MAX_TMP_SIZE_MB="10240" HOSTNAME_PLACEHOLDER="srv01.exemplo.com.br" # ----- utilitários ---------------------------------------------------------- log() { printf '\n\033[1;36m[%(%F %T)T]\033[0m %s\n' -1 "$*"; } warn() { printf '\n\033[1;33m[%(%F %T)T WARN]\033[0m %s\n' -1 "$*"; } die() { trap - ERR set +e printf '\n\033[1;31m[%(%F %T)T ERRO]\033[0m %s\n' -1 "$*" >&2 exit 1 } trap 'die "falhou na linha $LINENO (comando: $BASH_COMMAND)"' ERR # Escapa uma string para uso como valor JSON (aspas, barras e novas linhas) json_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\n'/\\n}" s="${s//$'\r'/}" s="${s//$'\t'/\\t}" printf '%s' "$s" } # URLs de webhook e Telegram normalmente contêm segredos. Passá-las como # argumento do curl as expõe em /proc/*/cmdline; usamos um config pelo stdin. curl_config_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" printf '%s' "$s" } curl_secret_url() { local url="$1" shift printf 'url = "%s"\n' "$(curl_config_escape "$url")" | \ curl -q --config - "$@" } whmapi_json() { command -v whmapi1 >/dev/null 2>&1 || return 1 # Destinos remotos de backup podem levar até 120 s para validar. timeout --signal=TERM --kill-after=15s 180 whmapi1 --output=json "$@" } whmapi_json_success() { local out command -v jq >/dev/null 2>&1 || return 1 out="$(whmapi_json "$@" 2>/dev/null)" || return 1 jq -e '.metadata.result == 1' >/dev/null 2>&1 <<<"$out" || return 1 printf '%s\n' "$out" } # Notificação best-effort (webhook Slack/Discord, Telegram e e-mail). # Nunca falha: todos os canais são opcionais e tolerantes a erro. notify() { local subject="$1" body="${2:-}" # /proc em vez do comando hostname: o PATH da fase 2 põe /usr/local/cpanel/bin # na frente, onde existe um `hostname` da cPanel que imprime texto de ajuda local msg="[cpanel-bootstrap] [${HOSTNAME_FQDN:-$(cat /proc/sys/kernel/hostname 2>/dev/null || echo servidor)}] ${subject}" [[ -n "$body" ]] && msg="${msg} ${body}" if [[ -n "${NOTIFY_WEBHOOK_URL:-}" ]]; then local json_msg payload local -a webhook_protocol_args json_msg="$(json_escape "$msg")" # Payload no formato nativo de cada serviço: Slack usa "text", # Discord usa "content"; outros webhooks recebem os dois campos. case "$NOTIFY_WEBHOOK_URL" in https://hooks.slack.com/services/*|https://hooks.slack-gov.com/services/*) payload="{\"text\":\"${json_msg}\"}" ;; *discord.com/api/webhooks*|*discordapp.com/api/webhooks*) payload="{\"content\":\"${json_msg}\"}" ;; *) payload="{\"text\":\"${json_msg}\",\"content\":\"${json_msg}\"}" ;; esac if [[ "$NOTIFY_WEBHOOK_URL" == https://* ]]; then webhook_protocol_args=(--proto '=https' --proto-redir '=https' --tlsv1.2) else webhook_protocol_args=(--proto '=http,https' --proto-redir '=http,https') fi curl_secret_url "$NOTIFY_WEBHOOK_URL" -fsS --max-time 15 \ "${webhook_protocol_args[@]}" \ -H 'Content-Type: application/json' -d "$payload" \ >/dev/null 2>&1 || true fi if [[ -n "${NOTIFY_TELEGRAM_BOT_TOKEN:-}" && -n "${NOTIFY_TELEGRAM_CHAT_ID:-}" ]]; then curl_secret_url \ "https://api.telegram.org/bot${NOTIFY_TELEGRAM_BOT_TOKEN}/sendMessage" \ -fsS --max-time 15 --proto '=https' --proto-redir '=https' --tlsv1.2 \ -d "chat_id=${NOTIFY_TELEGRAM_CHAT_ID}" \ --data-urlencode "text=${msg}" >/dev/null 2>&1 || true fi if [[ -n "${ADMIN_EMAIL:-}" && "$ADMIN_EMAIL" != "root@localhost" ]] && command -v mail >/dev/null 2>&1; then printf '%s\n' "$msg" | mail -s "[cpanel-bootstrap] ${subject}" "$ADMIN_EMAIL" >/dev/null 2>&1 || true fi } is_bool() { [[ "$1" =~ ^[01]$ ]] } is_integer() { [[ "$1" =~ ^[0-9]+$ ]] } is_valid_fqdn() { [[ "$1" =~ ^[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])?){2,}$ ]] } is_cprapid_hostname() { local host host="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" is_valid_fqdn "$host" && [[ "$host" == *.cprapid.com ]] } is_expected_or_cprapid_hostname() { local host host="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" [[ "$host" == "$HOSTNAME_FQDN" ]] || is_cprapid_hostname "$host" } is_valid_timezone_name() { [[ "$1" =~ ^[A-Za-z0-9._+-]+(/[A-Za-z0-9._+-]+)+$ ]] && [[ "$1" != *..* ]] } is_valid_php_size() { [[ "$1" =~ ^[0-9]+[MG]$ ]] } is_ipv4() { local ip="$1" octet local -a octets [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 local IFS=. read -r -a octets <<<"$ip" for octet in "${octets[@]}"; do (( 10#$octet >= 0 && 10#$octet <= 255 )) || return 1 done } validate_ssh_public_key() { local key="$1" key_file [[ -n "$key" ]] || return 0 [[ "$key" != *$'\n'* && "$key" != *$'\r'* ]] || return 1 command -v ssh-keygen >/dev/null 2>&1 || return 1 key_file="$(mktemp)" chmod 0600 "$key_file" printf '%s\n' "$key" > "$key_file" if ! ssh-keygen -l -f "$key_file" >/dev/null 2>&1; then rm -f "$key_file" return 1 fi rm -f "$key_file" } # Variáveis de configuração persistidas entre as fases. A fase 1 grava TODAS # após a validação — assim as fases 2/3 (systemd) usam exatamente os valores # que a fase 1 validou, inclusive overrides por variável de ambiente # (ex.: SSH_PORT=2222 bash cpanel-bootstrap.sh). RUNTIME_CONFIG_VARS=( HOSTNAME_FQDN SSH_PORT TIMEZONE CPANEL_TIER ADMIN_EMAIL CSF_CRITICAL_EMAIL_ALERTS CONFIRM_CONSOLE_ACCESS CONFIRM_STATIC_PUBLIC_IP ALLOW_DNS_MISMATCH KEEP_SSH_PORT_22_OPEN SERVER_LOCALE FTP_PASV_MIN FTP_PASV_MAX FTP_TLS_LEVEL PHP_FPM_MAX_CHILDREN PHP_MEMORY_LIMIT PHP_UPLOAD_MAX_FILESIZE PHP_POST_MAX_SIZE CSF_SYNFLOOD ADMIN_SSH_PUBKEY SSH_DISABLE_PASSWORD_AUTH ADMIN_ALLOW_IPS NOTIFY_WEBHOOK_URL NOTIFY_TELEGRAM_BOT_TOKEN NOTIFY_TELEGRAM_CHAT_ID SWAP_ENABLE MYSQL_TUNING BACKUP_ENABLE BACKUP_DAILY_RETENTION BACKUP_WEEKLY_ENABLE BACKUP_WEEKLY_RETENTION BACKUP_REMOTE_HOST BACKUP_REMOTE_PORT BACKUP_REMOTE_USER BACKUP_REMOTE_KEYFILE BACKUP_REMOTE_PATH SECURITY_TWEAKS MIN_PW_STRENGTH INSTALL_MODSECURITY_CRS AUTOSSL_PROVIDER AUTOSSL_INCLUDE_NEW_DOMAINS WHM_NS1 WHM_NS2 ANTIVIRUS WP_TOOLKIT_ENABLE EA4_PROFILE_PATH NGINX_PROXY_ENABLE REDIS_ENABLE REDIS_PASSWORD MIN_MEMORY_MB MIN_ROOT_FREE_MB MIN_HOME_FREE_MB MAX_PHASE2_ATTEMPTS CPANEL_INSTALL_TIMEOUT PHASE2_TIMEOUT MAX_PHASE3_ATTEMPTS MAX_FINISH_ATTEMPTS BACKUP_REQUIRE_REMOTE NOTIFY_ALLOW_HTTP ADMIN_SRC_IP SERVER_PUBLIC_IP ) validate_runtime_config_file() { local file="$1" uid mode [[ -f "$file" && ! -L "$file" ]] || die "runtime config inválido ou symlink: $file" uid="$(stat -c '%u' "$file" 2>/dev/null || echo '?')" mode="$(stat -c '%a' "$file" 2>/dev/null || echo '?')" [[ "$uid" == "0" && "$mode" == "600" ]] || \ die "runtime config precisa pertencer ao root e ter modo 0600 (atual uid=$uid modo=$mode)" } load_runtime_config() { [[ -f "$RUNTIME_CONFIG_PATH" ]] || return 0 validate_runtime_config_file "$RUNTIME_CONFIG_PATH" # Arquivo gerado pelo próprio bootstrap com valores escapados por printf %q. # shellcheck disable=SC1090 source "$RUNTIME_CONFIG_PATH" } # Na FASE 1, o runtime.env restaura apenas o "estado de diálogo" de execuções # anteriores (hostname informado e confirmações). Para todo o resto, o # cabeçalho/ambiente da execução atual SEMPRE vence — senão uma edição no # cabeçalho seria silenciosamente ignorada num re-run. load_runtime_config_phase1() { [[ -f "$RUNTIME_CONFIG_PATH" ]] || return 0 validate_runtime_config_file "$RUNTIME_CONFIG_PATH" local line while IFS= read -r line; do case "$line" in HOSTNAME_FQDN=*|CONFIRM_CONSOLE_ACCESS=*|CONFIRM_STATIC_PUBLIC_IP=*|ADMIN_SRC_IP=*) # Valores escapados por printf %q pelo próprio bootstrap (0600 root) eval "$line" ;; ADMIN_ALLOW_IPS=*) # UNIÃO com o valor atual do cabeçalho/ambiente, nunca # substituição: IPs de gestão acumulados em retomadas # anteriores (migração do ADMIN_SRC_IP) não podem sair da # whitelist anti-lockout numa 3ª execução vinda de outra rede. local saved_allow="" allow_ip eval "saved_allow=${line#ADMIN_ALLOW_IPS=}" for allow_ip in $saved_allow; do if [[ " ${ADMIN_ALLOW_IPS} " != *" ${allow_ip} "* ]]; then ADMIN_ALLOW_IPS="${ADMIN_ALLOW_IPS:+${ADMIN_ALLOW_IPS} }${allow_ip}" fi done ;; NOTIFY_WEBHOOK_URL=*) # A fase 1 persiste antes do preflight. Se ele falhar, conserva # a resposta secreta na retomada, mas um override atual não # vazio continua tendo precedência. if [[ -z "$NOTIFY_WEBHOOK_URL" ]]; then eval "$line" fi ;; esac done < "$RUNTIME_CONFIG_PATH" } persist_runtime_config() { local tmp_config var tmp_config="$(mktemp)" { for var in "${RUNTIME_CONFIG_VARS[@]}"; do printf '%s=%q\n' "$var" "${!var}" done if [[ -n "${TMP_SIZE_MB:-}" ]]; then printf 'TMP_SIZE_MB=%q\n' "$TMP_SIZE_MB" fi } > "$tmp_config" install -m 0600 "$tmp_config" "$RUNTIME_CONFIG_PATH" rm -f "$tmp_config" } # ----- marcadores de passo (retomada granular da fase 2) -------------------- # Uso: if step_begin nome_do_passo; then ... ; step_end; fi # Cada passo concluído grava ${STEP_DIR}/.done; re-execuções da fase 2 # pulam passos já concluídos em vez de re-tocar configs e reiniciar serviços. step_begin() { CURRENT_STEP="$1" if [[ -f "${STEP_DIR}/${CURRENT_STEP}.done" ]]; then log "Passo '${CURRENT_STEP}' já concluído em execução anterior — pulando" CURRENT_STEP="" return 1 fi echo "$CURRENT_STEP" > "${STATE_DIR}/current_step" 2>/dev/null || true return 0 } step_end() { touch "${STEP_DIR}/${CURRENT_STEP}.done" rm -f "${STEP_DIR}/${CURRENT_STEP}.warning" "${STEP_DIR}/${CURRENT_STEP}.failed" rm -f "${STATE_DIR}/current_step" CURRENT_STEP="" } step_warn() { local message="$1" printf '%s\n' "$message" > "${STEP_DIR}/${CURRENT_STEP}.warning" rm -f "${STATE_DIR}/current_step" warn "$message" CURRENT_STEP="" } step_fail() { local message="$1" printf '%s\n' "$message" > "${STEP_DIR}/${CURRENT_STEP}.failed" rm -f "${STATE_DIR}/current_step" CURRENT_STEP="" die "$message" } write_phase2_halt() { local reason="$1" printf '%s\n' "$reason" > "${STATE_DIR}/phase2.halt" } read_phase2_halt_reason() { local reason="" if [[ -s "${STATE_DIR}/phase2.halt" ]]; then reason="$(head -n 1 "${STATE_DIR}/phase2.halt" 2>/dev/null || true)" fi printf '%s\n' "${reason:-motivo não registrado; consulte ${LOG_DIR}/phase2.log}" } ask_yes_no() { local question="$1" answer [[ -r /dev/tty ]] || return 1 while true; do printf '%s [s/N]: ' "$question" >/dev/tty IFS= read -r answer /dev/tty ;; esac done } prompt_hostname() { local input candidate prompt_text [[ -r /dev/tty ]] || die "HOSTNAME_FQDN não definido e não há terminal interativo para perguntar" while true; do if [[ -n "$HOSTNAME_FQDN" && "$HOSTNAME_FQDN" != "$HOSTNAME_PLACEHOLDER" ]]; then prompt_text="Hostname FQDN do servidor [${HOSTNAME_FQDN}]: " else prompt_text="Hostname FQDN do servidor (ex: srv01.seudominio.com.br): " fi printf '%s' "$prompt_text" >/dev/tty IFS= read -r input /dev/tty done } is_slack_webhook_url() { local url="$1" (( ${#url} <= 1024 )) || return 1 [[ "$url" =~ ^https://hooks\.slack(-gov)?\.com/services/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$ ]] } prompt_notify_webhook() { local input tty_fd # Se veio do ambiente, mantém o valor nesta shell, mas impede que o # segredo seja herdado por yum, systemctl e todos os demais filhos. export -n NOTIFY_WEBHOOK_URL 2>/dev/null || true # Testa a abertura real do terminal controlador. Apenas `-r /dev/tty` # pode retornar verdadeiro mesmo em alguns contextos sem TTY utilizável. # O grupo { } evita que o 2>/dev/null persista no shell (exec sem comando # torna permanentes TODOS os redirects, inclusive o do stderr). if ! { exec {tty_fd}<>/dev/tty; } 2>/dev/null; then return 0 fi # Overrides pelo cabeçalho/ambiente continuam não interativos e nunca são # reexibidos, pois a URL é um segredo que permite publicar no canal. if [[ -n "$NOTIFY_WEBHOOK_URL" ]]; then printf 'Notificações: webhook já configurado; mantendo a URL oculta.\n' >&"$tty_fd" exec {tty_fd}>&- return 0 fi while true; do printf 'NOTIFY_WEBHOOK_URL do Slack (Enter para desativar; colagem oculta): ' >&"$tty_fd" IFS= read -r -s input <&"$tty_fd" || die "não foi possível ler o webhook do Slack" printf '\n' >&"$tty_fd" if [[ -z "$input" ]]; then NOTIFY_WEBHOOK_URL="" printf 'Notificações do Slack desativadas.\n' >&"$tty_fd" exec {tty_fd}>&- return 0 fi if is_slack_webhook_url "$input"; then NOTIFY_WEBHOOK_URL="$input" input="" printf 'Webhook do Slack configurado; a URL será persistida com acesso restrito ao root.\n' >&"$tty_fd" exec {tty_fd}>&- return 0 fi input="" printf 'Webhook inválido. Use https://hooks.slack.com/services/... ou pressione Enter.\n' >&"$tty_fd" done } collect_interactive_config() { # Captura o IP de origem da sessão SSH para whitelist anti-lockout no # CSF/cPHulk (fase 2). A sessão ATUAL sempre vence: numa retomada feita # de outro IP, o IP persistido da tentativa anterior migra para # ADMIN_ALLOW_IPS — os dois ficam protegidos, e quem está no teclado # agora nunca fica fora da whitelist. if [[ -n "${SSH_CLIENT:-}" ]]; then local src_ip="${SSH_CLIENT%% *}" if is_ipv4 "$src_ip"; then if [[ -n "${ADMIN_SRC_IP:-}" && "$ADMIN_SRC_IP" != "$src_ip" ]] && \ [[ " ${ADMIN_ALLOW_IPS} " != *" ${ADMIN_SRC_IP} "* ]]; then ADMIN_ALLOW_IPS="${ADMIN_ALLOW_IPS:+${ADMIN_ALLOW_IPS} }${ADMIN_SRC_IP}" fi ADMIN_SRC_IP="$src_ip" fi fi # `-r /dev/tty` é verdadeiro mesmo sem terminal controlador (o device node # existe com modo 666); só a abertura real prova que há TTY utilizável — # sem isso, um ssh sem PTY morreria no primeiro printf >/dev/tty via trap # ERR genérico em vez de seguir o caminho não interativo. # O grupo { } é obrigatório: num exec sem comando, TODO redirect persiste # no shell — um 2>/dev/null direto no exec emudeceria o stderr (die, trap # ERR) do processo inteiro dali em diante. local cfg_tty_fd if ! { exec {cfg_tty_fd}<>/dev/tty; } 2>/dev/null; then return 0 fi exec {cfg_tty_fd}>&- prompt_hostname if [[ "$CONFIRM_CONSOLE_ACCESS" != "1" ]]; then if ask_yes_no "Você confirma que tem acesso ao console/VNC do provedor caso SSH/firewall falhe?"; then CONFIRM_CONSOLE_ACCESS="1" fi fi if [[ "$CONFIRM_STATIC_PUBLIC_IP" != "1" ]]; then if ask_yes_no "Você confirma que o servidor usa IPv4 público estático para hostname/licença cPanel?"; then CONFIRM_STATIC_PUBLIC_IP="1" fi fi } validate_config() { [[ -n "$HOSTNAME_FQDN" && "$HOSTNAME_FQDN" != "$HOSTNAME_PLACEHOLDER" ]] || \ die "HOSTNAME_FQDN não definido; execute a fase 1 em terminal interativo para informar o hostname" # DNS não diferencia maiúsculas, mas systemd/cPanel normalmente persistem # o hostname em minúsculas. Normalizar uma vez evita divergências eternas # nas comparações de estado após set_hostname. HOSTNAME_FQDN="${HOSTNAME_FQDN,,}" if ! is_valid_fqdn "$HOSTNAME_FQDN"; then die "HOSTNAME_FQDN='$HOSTNAME_FQDN' inválido. Use algo como: srv01.seudominio.com.br" fi (( ${#HOSTNAME_FQDN} <= 60 )) || \ die "HOSTNAME_FQDN excede o máximo de 60 caracteres aceito pelo cPanel" local hostname_first_label="${HOSTNAME_FQDN%%.*}" [[ "${hostname_first_label,,}" != "www" && ! "$hostname_first_label" =~ ^[0-9] ]] || \ die "o hostname não pode começar com 'www' nem com número" [[ "$CPANEL_TIER" =~ ^(stable|release|lts|current|edge)$ ]] || \ die "CPANEL_TIER='$CPANEL_TIER' inválido (use stable, release, lts, current ou edge)" [[ "$SERVER_LOCALE" =~ ^[a-z]{2,3}(_[a-z]{2})?$ ]] || \ die "SERVER_LOCALE='$SERVER_LOCALE' inválido (ex.: pt_br, en, es)" is_integer "$SSH_PORT" && (( SSH_PORT >= 1 && SSH_PORT <= 65535 )) || \ die "SSH_PORT='$SSH_PORT' inválido (use 1-65535)" (( SSH_PORT == 22 || SSH_PORT >= 1024 )) || \ die "SSH_PORT abaixo de 1024 só é aceita quando for a porta padrão 22" case "$SSH_PORT" in 2077|2078|2079|2080|2082|2083|2086|2087|2095|2096|3306|6379) die "SSH_PORT=${SSH_PORT} conflita com uma porta reservada por cPanel/banco/Redis" ;; esac is_integer "$FTP_PASV_MIN" && is_integer "$FTP_PASV_MAX" && \ (( FTP_PASV_MIN >= 1024 && FTP_PASV_MAX <= 65535 && FTP_PASV_MIN < FTP_PASV_MAX )) || \ die "FTP_PASV_MIN/FTP_PASV_MAX inválidos (use range 1024-65535)" if (( SSH_PORT >= FTP_PASV_MIN && SSH_PORT <= FTP_PASV_MAX )); then die "SSH_PORT=$SSH_PORT conflita com o range FTP passivo ${FTP_PASV_MIN}-${FTP_PASV_MAX}" fi [[ "$FTP_TLS_LEVEL" =~ ^[0-3]$ ]] || die "FTP_TLS_LEVEL precisa ser 0, 1, 2 ou 3" is_bool "$CSF_CRITICAL_EMAIL_ALERTS" || die "CSF_CRITICAL_EMAIL_ALERTS precisa ser 0 ou 1" is_bool "$CONFIRM_CONSOLE_ACCESS" || die "CONFIRM_CONSOLE_ACCESS precisa ser 0 ou 1" is_bool "$CONFIRM_STATIC_PUBLIC_IP" || die "CONFIRM_STATIC_PUBLIC_IP precisa ser 0 ou 1" is_bool "$ALLOW_DNS_MISMATCH" || die "ALLOW_DNS_MISMATCH precisa ser 0 ou 1" is_bool "$KEEP_SSH_PORT_22_OPEN" || die "KEEP_SSH_PORT_22_OPEN precisa ser 0 ou 1" is_bool "$CSF_SYNFLOOD" || die "CSF_SYNFLOOD precisa ser 0 ou 1" is_bool "$SSH_DISABLE_PASSWORD_AUTH" || die "SSH_DISABLE_PASSWORD_AUTH precisa ser 0 ou 1" is_bool "$SWAP_ENABLE" || die "SWAP_ENABLE precisa ser 0 ou 1" is_bool "$MYSQL_TUNING" || die "MYSQL_TUNING precisa ser 0 ou 1" is_bool "$BACKUP_ENABLE" || die "BACKUP_ENABLE precisa ser 0 ou 1" is_bool "$BACKUP_WEEKLY_ENABLE" || die "BACKUP_WEEKLY_ENABLE precisa ser 0 ou 1" is_bool "$SECURITY_TWEAKS" || die "SECURITY_TWEAKS precisa ser 0 ou 1" is_bool "$INSTALL_MODSECURITY_CRS" || die "INSTALL_MODSECURITY_CRS precisa ser 0 ou 1" is_bool "$AUTOSSL_INCLUDE_NEW_DOMAINS" || die "AUTOSSL_INCLUDE_NEW_DOMAINS precisa ser 0 ou 1" is_bool "$NGINX_PROXY_ENABLE" || die "NGINX_PROXY_ENABLE precisa ser 0 ou 1" is_bool "$REDIS_ENABLE" || die "REDIS_ENABLE precisa ser 0 ou 1" is_bool "$WP_TOOLKIT_ENABLE" || die "WP_TOOLKIT_ENABLE precisa ser 0 ou 1" is_bool "$BACKUP_REQUIRE_REMOTE" || die "BACKUP_REQUIRE_REMOTE precisa ser 0 ou 1" is_bool "$NOTIFY_ALLOW_HTTP" || die "NOTIFY_ALLOW_HTTP precisa ser 0 ou 1" [[ "$ANTIVIRUS" =~ ^(imunifyav|clamav|none)$ ]] || \ die "ANTIVIRUS='$ANTIVIRUS' inválido (use imunifyav, clamav ou none)" is_integer "$MIN_MEMORY_MB" || die "MIN_MEMORY_MB precisa ser numérico" is_integer "$MIN_ROOT_FREE_MB" || die "MIN_ROOT_FREE_MB precisa ser numérico" is_integer "$MIN_HOME_FREE_MB" || die "MIN_HOME_FREE_MB precisa ser numérico" is_integer "$MAX_PHASE2_ATTEMPTS" && (( MAX_PHASE2_ATTEMPTS >= 1 && MAX_PHASE2_ATTEMPTS <= 50 )) || \ die "MAX_PHASE2_ATTEMPTS precisa ser um número entre 1 e 50" is_integer "$CPANEL_INSTALL_TIMEOUT" && (( CPANEL_INSTALL_TIMEOUT >= 1800 && CPANEL_INSTALL_TIMEOUT <= 43200 )) || \ die "CPANEL_INSTALL_TIMEOUT precisa estar entre 1800 e 43200 segundos" is_integer "$PHASE2_TIMEOUT" && (( PHASE2_TIMEOUT >= 3600 && PHASE2_TIMEOUT <= 604800 )) || \ die "PHASE2_TIMEOUT precisa estar entre 3600 e 604800 segundos" is_integer "$MAX_PHASE3_ATTEMPTS" && (( MAX_PHASE3_ATTEMPTS >= 1 && MAX_PHASE3_ATTEMPTS <= 20 )) || \ die "MAX_PHASE3_ATTEMPTS precisa estar entre 1 e 20" is_integer "$MAX_FINISH_ATTEMPTS" && (( MAX_FINISH_ATTEMPTS >= 1 && MAX_FINISH_ATTEMPTS <= 100 )) || \ die "MAX_FINISH_ATTEMPTS precisa estar entre 1 e 100" local minimum_phase2_timeout minimum_phase2_timeout=$(( CPANEL_INSTALL_TIMEOUT + 7200 )) (( PHASE2_TIMEOUT >= minimum_phase2_timeout )) || \ die "PHASE2_TIMEOUT=${PHASE2_TIMEOUT}s não comporta o instalador único de ${CPANEL_INSTALL_TIMEOUT}s + configuração pós-cPanel (mínimo: ${minimum_phase2_timeout}s)" is_integer "$MIN_PW_STRENGTH" && (( MIN_PW_STRENGTH >= 1 && MIN_PW_STRENGTH <= 100 )) || \ die "MIN_PW_STRENGTH precisa ser um número entre 1 e 100" is_integer "$BACKUP_DAILY_RETENTION" && (( BACKUP_DAILY_RETENTION >= 1 && BACKUP_DAILY_RETENTION <= 30 )) || \ die "BACKUP_DAILY_RETENTION precisa ser um número entre 1 e 30" is_integer "$BACKUP_WEEKLY_RETENTION" && (( BACKUP_WEEKLY_RETENTION >= 1 && BACKUP_WEEKLY_RETENTION <= 12 )) || \ die "BACKUP_WEEKLY_RETENTION precisa ser um número entre 1 e 12" is_integer "$BACKUP_REMOTE_PORT" && (( BACKUP_REMOTE_PORT >= 1 && BACKUP_REMOTE_PORT <= 65535 )) || \ die "BACKUP_REMOTE_PORT inválido" is_valid_php_size "$PHP_MEMORY_LIMIT" || die "PHP_MEMORY_LIMIT='$PHP_MEMORY_LIMIT' inválido (ex: 512M)" is_valid_php_size "$PHP_UPLOAD_MAX_FILESIZE" || die "PHP_UPLOAD_MAX_FILESIZE='$PHP_UPLOAD_MAX_FILESIZE' inválido (ex: 256M)" is_valid_php_size "$PHP_POST_MAX_SIZE" || die "PHP_POST_MAX_SIZE='$PHP_POST_MAX_SIZE' inválido (ex: 272M)" [[ "$CONFIRM_CONSOLE_ACCESS" == "1" ]] || \ die "defina CONFIRM_CONSOLE_ACCESS=1 após confirmar acesso ao console/VNC do provedor" [[ "$CONFIRM_STATIC_PUBLIC_IP" == "1" ]] || \ die "defina CONFIRM_STATIC_PUBLIC_IP=1 após confirmar que o IPv4 público é estático" if [[ -n "${TMP_SIZE_MB:-}" ]]; then is_integer "$TMP_SIZE_MB" || die "TMP_SIZE_MB='$TMP_SIZE_MB' inválido" fi if [[ -n "$PHP_FPM_MAX_CHILDREN" ]]; then is_integer "$PHP_FPM_MAX_CHILDREN" && (( PHP_FPM_MAX_CHILDREN >= 2 && PHP_FPM_MAX_CHILDREN <= 100 )) || \ die "PHP_FPM_MAX_CHILDREN='$PHP_FPM_MAX_CHILDREN' inválido (use 2-100 ou deixe vazio)" fi if [[ "$ADMIN_EMAIL" != "root@localhost" && ! "$ADMIN_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$ ]]; then die "ADMIN_EMAIL='$ADMIN_EMAIL' inválido" fi is_valid_timezone_name "$TIMEZONE" || \ die "TIMEZONE='$TIMEZONE' inválido. Exemplo válido: America/Sao_Paulo" if [[ -n "$ADMIN_SSH_PUBKEY" ]] && ! validate_ssh_public_key "$ADMIN_SSH_PUBKEY"; then die "ADMIN_SSH_PUBKEY não é uma chave pública OpenSSH válida (ssh-keygen rejeitou)" fi if [[ "$SSH_DISABLE_PASSWORD_AUTH" == "1" && -z "$ADMIN_SSH_PUBKEY" ]]; then die "SSH_DISABLE_PASSWORD_AUTH=1 exige ADMIN_SSH_PUBKEY preenchida (senão você perde o acesso)" fi if [[ -n "$NOTIFY_WEBHOOK_URL" ]]; then [[ "$NOTIFY_WEBHOOK_URL" != *[[:space:]]* ]] || die "NOTIFY_WEBHOOK_URL não pode conter espaços" if [[ "$NOTIFY_WEBHOOK_URL" != https://* && "$NOTIFY_ALLOW_HTTP" != "1" ]]; then die "NOTIFY_WEBHOOK_URL precisa usar HTTPS (HTTP exige NOTIFY_ALLOW_HTTP=1)" fi [[ "$NOTIFY_WEBHOOK_URL" =~ ^https?:// ]] || die "NOTIFY_WEBHOOK_URL inválida" fi if [[ -n "$NOTIFY_TELEGRAM_BOT_TOKEN" || -n "$NOTIFY_TELEGRAM_CHAT_ID" ]]; then [[ "$NOTIFY_TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || \ die "NOTIFY_TELEGRAM_BOT_TOKEN inválido ou incompleto" [[ "$NOTIFY_TELEGRAM_CHAT_ID" =~ ^(-?[0-9]+|@[A-Za-z][A-Za-z0-9_]{4,31})$ ]] || \ die "NOTIFY_TELEGRAM_CHAT_ID inválido ou incompleto" fi if [[ -n "$WHM_NS1" ]] && ! is_valid_fqdn "$WHM_NS1"; then die "WHM_NS1='$WHM_NS1' inválido" fi if [[ -n "$WHM_NS2" ]] && ! is_valid_fqdn "$WHM_NS2"; then die "WHM_NS2='$WHM_NS2' inválido" fi if [[ -n "$AUTOSSL_PROVIDER" && ! "$AUTOSSL_PROVIDER" =~ ^[A-Za-z0-9_]+$ ]]; then die "AUTOSSL_PROVIDER='$AUTOSSL_PROVIDER' inválido" fi local allow_ip for allow_ip in $ADMIN_ALLOW_IPS; do is_ipv4 "$allow_ip" || die "ADMIN_ALLOW_IPS contém IP inválido: '$allow_ip'" done if [[ -n "$BACKUP_REMOTE_HOST" ]]; then [[ "$BACKUP_REMOTE_HOST" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || \ die "BACKUP_REMOTE_HOST contém caracteres inválidos" [[ "$BACKUP_REMOTE_HOST" != *..* ]] || die "BACKUP_REMOTE_HOST não pode conter pontos consecutivos" [[ "$BACKUP_REMOTE_USER" =~ ^[A-Za-z0-9._-]{1,64}$ ]] || \ die "BACKUP_REMOTE_USER inválido" [[ -n "$BACKUP_REMOTE_PATH" && ${#BACKUP_REMOTE_PATH} -le 255 && \ "$BACKUP_REMOTE_PATH" != *..* && "$BACKUP_REMOTE_PATH" =~ ^[A-Za-z0-9._/-]+$ ]] || \ die "BACKUP_REMOTE_PATH inválido (use caminho sem espaços nem '..')" [[ -n "$BACKUP_REMOTE_KEYFILE" ]] || die "BACKUP_REMOTE_KEYFILE obrigatório quando BACKUP_REMOTE_HOST está definido" [[ -f "$BACKUP_REMOTE_KEYFILE" && ! -L "$BACKUP_REMOTE_KEYFILE" ]] || \ die "BACKUP_REMOTE_KEYFILE precisa ser arquivo regular e não symlink" local backup_key_uid backup_key_mode backup_key_uid="$(stat -c '%u' "$BACKUP_REMOTE_KEYFILE" 2>/dev/null || echo '?')" backup_key_mode="$(stat -c '%a' "$BACKUP_REMOTE_KEYFILE" 2>/dev/null || echo '?')" [[ "$backup_key_uid" == "0" && "$backup_key_mode" =~ ^(400|600)$ ]] || \ die "BACKUP_REMOTE_KEYFILE precisa pertencer ao root e ter modo 0400/0600" ssh-keygen -l -f "$BACKUP_REMOTE_KEYFILE" >/dev/null 2>&1 || \ die "BACKUP_REMOTE_KEYFILE não parece uma chave SSH válida" elif [[ "$BACKUP_ENABLE" == "1" && "$BACKUP_REQUIRE_REMOTE" == "1" ]]; then die "BACKUP_REQUIRE_REMOTE=1 exige BACKUP_REMOTE_HOST e chave SFTP" fi if [[ "$REDIS_ENABLE" == "1" ]]; then [[ "$REDIS_PASSWORD" =~ ^[A-Za-z0-9._~!@#%+=:-]{16,128}$ ]] || \ die "REDIS_ENABLE=1 exige REDIS_PASSWORD com 16-128 caracteres seguros" fi } # Detecta o IPv4 público consultando múltiplos provedores (tolerante à queda # de qualquer um deles — antes dependíamos só do api.ipify.org). detect_public_ipv4() { local ip url for url in https://api.ipify.org https://checkip.amazonaws.com https://ipv4.icanhazip.com https://ifconfig.me/ip; do ip="$(curl -4 -fsS --max-time 8 \ --proto '=https' --proto-redir '=https' --tlsv1.2 \ "$url" 2>/dev/null | tr -d '[:space:]' || true)" if is_ipv4 "$ip"; then printf '%s\n' "$ip" return 0 fi done return 1 } resolve_hostname_ipv4s() { local host="$1" ips="" if command -v dig >/dev/null 2>&1; then ips="$(dig +short A "$host" 2>/dev/null | awk '/^([0-9]{1,3}\.){3}[0-9]{1,3}$/ {print}' | sort -u || true)" fi if [[ -z "$ips" ]] && command -v curl >/dev/null 2>&1; then ips="$( { curl -4 -fsS --max-time 10 \ --proto '=https' --proto-redir '=https' --tlsv1.2 \ -H 'accept: application/dns-json' \ "https://cloudflare-dns.com/dns-query?name=${host}&type=A" | grep -oE '"data":"([0-9]{1,3}\.){3}[0-9]{1,3}"' | cut -d'"' -f4 } 2>/dev/null | sort -u || true )" fi printf '%s\n' "$ips" | awk 'NF' | sort -u } # O instalador do cPanel usa o resolver de /etc/resolv.conf, não o fallback # DoH do preflight. Esta sonda preserva status como SERVFAIL/REFUSED para não # mascarar exatamente a falha que faria o cPanel trocar o FQDN por cprapid.com. cpanel_hostname_dns_probe() { local host="$1" output status ips command -v dig >/dev/null 2>&1 || { printf '%s|\n' 'TIMEOUT' return 0 } output="$(dig +time=5 +tries=1 +noall +comments +answer A "$host" 2>&1 || true)" status="$(sed -nE 's/^;;.*status: ([A-Z]+),.*/\1/p' <<<"$output" | head -n 1)" [[ -n "$status" ]] || status="TIMEOUT" ips="$(awk ' $1 !~ /^;/ && $4 == "A" && $5 ~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/ { print $5 } ' <<<"$output" | sort -u | paste -sd, -)" printf '%s|%s\n' "$status" "$ips" } # Retornos: 0 = NOERROR + IP esperado; 2 = resolver local instável/erro; # 3 = resposta DNS válida, porém NXDOMAIN ou A divergente. validate_cpanel_hostname_dns_gate() { local attempts="${1:-5}" delay="${2:-5}" local attempt result status ips CPANEL_HOSTNAME_DNS_STATUS="" CPANEL_HOSTNAME_DNS_IPV4="" for (( attempt=1; attempt<=attempts; attempt++ )); do result="$(cpanel_hostname_dns_probe "$HOSTNAME_FQDN")" status="${result%%|*}" ips="${result#*|}" CPANEL_HOSTNAME_DNS_STATUS="$status" CPANEL_HOSTNAME_DNS_IPV4="$ips" # Hostname de servidor deve ter um único A. Aceitar o IP esperado junto # com um IP antigo tornaria DCV/WHM intermitentes durante a propagação. if [[ "$status" == "NOERROR" && "$ips" == "$SERVER_PUBLIC_IP" ]]; then log " ✓ Resolver local confirmou ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP}" return 0 fi warn "Resolver local ainda não confirmou o hostname (tentativa ${attempt}/${attempts}: status=${status}, A=${ips:-vazio})" if (( attempt < attempts && delay > 0 )); then sleep "$delay" fi done case "$CPANEL_HOSTNAME_DNS_STATUS" in NOERROR|NXDOMAIN) return 3 ;; *) return 2 ;; esac } require_space_mb() { local path="$1" min_mb="$2" avail_mb avail_mb="$(df -Pm "$path" | awk 'NR==2 {print $4}')" is_integer "$avail_mb" || die "não foi possível medir espaço livre em $path" (( avail_mb >= min_mb )) || \ die "espaço livre insuficiente em $path: ${avail_mb} MB disponível, mínimo ${min_mb} MB" log " ✓ Espaço livre em $path: ${avail_mb} MB" } cpanel_files_present() { [[ -x /usr/local/cpanel/cpanel && -x /usr/local/cpanel/bin/whmapi1 ]] } cpanel_version_output_valid() { local version="$1" # Formatos observados/documentados nas versões atuais: # 134.0 (build 44) — saída de `cpanel -V` no 11.134 # 11.134.0.44 — /usr/local/cpanel/version e APIs # Também aceita a forma abreviada sem build, mas rejeita sufixos livres. [[ "$version" =~ ^(11\.)?[0-9]+\.[0-9]+(\.[0-9]+)?([[:space:]]+\(build[[:space:]]+[0-9]+\))?$ ]] } cpanel_install_log_complete() { [[ -f /var/log/cpanel-install.log ]] || return 1 grep -Eqi 'cPanel install finished in .*|Congratulations! Your installation of cPanel & WHM .* is now complete|Thank you for installing cPanel & WHM[[:space:]]+[0-9]+' \ /var/log/cpanel-install.log } cpanel_install_recorded_exit_zero() { # Só existe quando esta mesma execução retornou do `timeout sh latest` e # gravou o resultado. Na recuperação de uma queda abrupta não inventamos # um exit code: continuamos exigindo a frase terminal do log. [[ -f "$CPANEL_INSTALL_FAILED_MARKER" && ! -L "$CPANEL_INSTALL_FAILED_MARKER" ]] || return 1 grep -qxF 'exit_code=0' "$CPANEL_INSTALL_FAILED_MARKER" } cpanel_runtime_ready() { local version cpanel_files_present || return 1 [[ -s "$CPANEL_CONFIG" ]] || return 1 version="$(timeout 30 /usr/local/cpanel/cpanel -V 2>/dev/null)" || return 1 cpanel_version_output_valid "$version" } wait_for_cpanel_runtime_ready() { local wait_seconds="${1:-300}" deadline deadline=$(( SECONDS + wait_seconds )) while (( SECONDS < deadline )); do if cpanel_runtime_ready; then return 0 fi sleep 10 done # Último check na borda do prazo, sem acrescentar outro sleep. cpanel_runtime_ready } is_cpanel_installed() { cpanel_runtime_ready || return 1 [[ -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]] || cpanel_install_log_complete } cpanel_install_footprint_present() { [[ -e /usr/local/cpanel || -e "$CPANEL_CONFIG" || \ -e /var/log/cpanel-install.log || -e "$CPANEL_INSTALL_STARTED_MARKER" || \ -e "$CPANEL_INSTALL_SUCCESS_MARKER" || -e "$CPANEL_INSTALL_FAILED_MARKER" ]] } reconcile_cpanel_installer_state() { local reason # DONE é monotônico: já houve validação forte numa execução anterior e o # `latest` jamais pode voltar a ser candidato. Se o runtime estiver fora, # a instalação NÃO é rebaixada a parcial — mas uma sonda transitória # (cpsrvd reiniciando, chkservd no meio de um restart) não pode suspender # a fase 2 sozinha: espera até 3 min e, se ainda falhar, consome # retentativas normais do timer antes do halt de diagnóstico. if [[ -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]]; then rm -f "$CPANEL_INSTALL_FAILED_MARKER" if wait_for_cpanel_runtime_ready 180; then rm -f "${STATE_DIR}/installer-probe.attempts" return 0 fi local probe_attempts=0 if [[ -f "${STATE_DIR}/installer-probe.attempts" ]]; then probe_attempts="$(cat "${STATE_DIR}/installer-probe.attempts" 2>/dev/null || echo 0)" is_integer "$probe_attempts" || probe_attempts=0 fi probe_attempts=$((probe_attempts + 1)) echo "$probe_attempts" > "${STATE_DIR}/installer-probe.attempts" if (( probe_attempts < 3 )); then die "instalação do cPanel já validada, mas o runtime não respondeu em 3 min (sonda ${probe_attempts}/3) — nova tentativa automática em ~15 min" fi reason="a instalação do cPanel já havia sido validada, mas o runtime segue indisponível após ${probe_attempts} sondas espaçadas; diagnostique os serviços do cPanel e não execute o latest. Após corrigir, remova ${STATE_DIR}/phase2.halt para retomar as configurações" write_phase2_halt "$reason" # Remover o halt libera uma escada completa de novas sondas (mesmo # padrão do phase2.attempts no teto da fase 2). rm -f "${STATE_DIR}/installer-probe.attempts" notify "Fase 2 SUSPENSA — runtime do cPanel indisponível" "$reason" die "$reason" fi # Recupera o caso em que o instalador terminou, mas o bootstrap ainda não # publicou DONE. Runtime + frase terminal são provas fortes após uma queda; # quando preservamos o exit 0 da execução anterior, ele substitui a frase # para tolerar mudanças futuras no texto do instalador. if cpanel_runtime_ready && \ { cpanel_install_log_complete || cpanel_install_recorded_exit_zero; }; then touch "$CPANEL_INSTALL_SUCCESS_MARKER" || return 1 log " ✓ Instalação completa do cPanel recuperada por runtime + log/exit 0 persistido" rm -f "$CPANEL_INSTALL_FAILED_MARKER" return 0 fi # Qualquer rastro após o início torna a reexecução não segura. O `latest` # exige um SO limpo e recusa instalações parciais; suspendemos imediatamente # em vez de transformar o timer da fase 2 em um loop de reinstalação. if cpanel_install_footprint_present; then if [[ -f "$CPANEL_INSTALL_STARTED_MARKER" ]]; then reason="instalador do cPanel foi iniciado ou estava prestes a iniciar, mas runtime + log não confirmam conclusão; restaure o snapshot/reinstale o AlmaLinux e não execute o latest novamente" else reason="rastros de cPanel parcial foram encontrados antes da instalação; use um AlmaLinux limpo ou restaure o snapshot" fi { printf 'failed_at=%s\n' "$(date --iso-8601=seconds)" printf 'reason=%s\n' "$reason" } > "$CPANEL_INSTALL_FAILED_MARKER" write_phase2_halt "$reason" notify "Fase 2 SUSPENSA — instalação parcial do cPanel" "$reason Log: ${LOG_DIR}/cpanel-install.log" die "$reason" fi } imunifyav_installed() { rpm -q imunify-antivirus >/dev/null 2>&1 || \ rpm -q imunify-antivirus-cpanel >/dev/null 2>&1 } imunifyav_runtime_mode() { imunifyav_installed || return 1 command -v imunify-antivirus >/dev/null 2>&1 || return 1 timeout 30 imunify-antivirus version >/dev/null 2>&1 || return 1 # O probe consulta o RPC local e funciona tanto com daemon residente como # com socket activation. Não inicia scan nem depende de resposta da nuvem. if timeout 30 imunify-antivirus malware on-demand status --json \ >/dev/null 2>&1; then printf '%s\n' 'RPC local operacional' return 0 fi if systemctl is-active --quiet imunify-antivirus.service 2>/dev/null; then printf '%s\n' 'serviço residente ativo' return 0 fi # ImunifyAV 8.8+ pode encerrar o daemon quando ocioso (Result=success) e # reativá-lo sob demanda pelo socket principal. Nesse modelo, exigir que o # service fique permanentemente active gera um falso negativo na fase 3. if systemctl list-units 'imunify-antivirus*.socket' --type=socket \ --state=active --no-legend --plain 2>/dev/null | grep -q '^imunify-antivirus'; then printf '%s\n' 'ativação por socket ativa' return 0 fi return 1 } imunifyav_operational() { imunifyav_runtime_mode >/dev/null } validate_platform_basics() { [[ "$(uname -m)" == "x86_64" ]] || die "arquitetura não suportada: $(uname -m) (cPanel exige x86_64)" [[ "$(getconf LONG_BIT 2>/dev/null || echo 0)" == "64" ]] || die "sistema precisa ser 64-bit" local root_fstype root_fstype="$(findmnt -n -o FSTYPE / 2>/dev/null || true)" [[ "$root_fstype" =~ ^(xfs|ext4)$ ]] || \ die "filesystem de / não suportado pelo perfil RapidCloud: '${root_fstype:-n/d}' (use XFS ou ext4)" command -v flock >/dev/null 2>&1 || die "flock não encontrado (filesystem/OS incompatível com a retomada segura)" command -v prlimit >/dev/null 2>&1 || die "prlimit não encontrado (instale util-linux)" local inode_total inode_total="$(df -Pi / 2>/dev/null | awk 'NR==2 {print $2}')" if is_integer "$inode_total" && (( inode_total < 1000000 )); then die "quantidade de inodes insuficiente em /: ${inode_total} (mínimo recomendado: 1000000)" fi } run_preflight() { log "Executando preflight antes da instalação" command -v curl >/dev/null 2>&1 || die "curl não encontrado; instale curl antes de rodar o bootstrap" validate_platform_basics if cpanel_install_footprint_present; then if [[ -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]]; then if cpanel_runtime_ready; then die "cPanel já está instalado e validado; este bootstrap é exclusivo para AlmaLinux recém-instalado" fi die "o cPanel já havia sido validado neste host, mas o runtime está indisponível; diagnostique a instalação existente e não execute o latest nem restaure apenas por este preflight" fi if is_cpanel_installed; then die "cPanel já está instalado; este bootstrap é exclusivo para AlmaLinux recém-instalado" fi die "foram encontrados rastros de instalação parcial do cPanel. O instalador oficial exige servidor limpo; restaure o snapshot/reinstale o AlmaLinux em vez de executar o latest novamente" fi local conflict_path for conflict_path in /usr/local/directadmin /usr/local/psa /usr/local/interworx; do [[ ! -e "$conflict_path" ]] || die "encontrado painel conflitante em $conflict_path" done # 'psa-' (com hífen) casa o pacote core do Plesk (psa-18.x) sem capturar o # psacct do AlmaLinux (contabilidade de processos), que não é painel. if rpm -qa | grep -Eiq '^(psa-|plesk|directadmin|interworx)'; then die "pacotes de outro painel detectados; use um AlmaLinux 9 recém-instalado" fi local conflict_pkg for conflict_pkg in httpd nginx mariadb-server mysql-server; do if rpm -q "$conflict_pkg" >/dev/null 2>&1; then die "pacote conflitante detectado: $conflict_pkg (use instalação mínima/limpa)" fi done local conflict_mail_pkg for conflict_mail_pkg in postfix dovecot; do if rpm -q "$conflict_mail_pkg" >/dev/null 2>&1; then die "pacote de e-mail pré-existente detectado: $conflict_mail_pkg (use AlmaLinux 9 minimal limpo)" fi done local mem_mb mem_mb="$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo)" is_integer "$mem_mb" || die "não foi possível medir memória RAM" (( mem_mb >= MIN_MEMORY_MB )) || \ die "RAM insuficiente: ${mem_mb} MB detectados, mínimo ${MIN_MEMORY_MB} MB" if [[ "$ANTIVIRUS" == "clamav" ]] && (( mem_mb < 3072 )); then die "ANTIVIRUS=clamav exige pelo menos 3072 MB de RAM (detectado: ${mem_mb} MB)" fi log " ✓ Memória RAM: ${mem_mb} MB" require_space_mb / "$MIN_ROOT_FREE_MB" if [[ -d /home ]]; then require_space_mb /home "$MIN_HOME_FREE_MB" fi # Particionamento com /var, /usr ou /opt separados (templates CIS/ # corporativos): o grosso do cPanel vai para /usr/local/cpanel, # /var/cpanel, /var/lib/mysql e /opt/cpanel — a checagem de / acima # mediria a partição errada e a instalação estouraria no meio. local mnt for mnt in /var /usr /usr/local; do if mountpoint -q "$mnt" 2>/dev/null; then require_space_mb "$mnt" 20480 fi done if mountpoint -q /opt 2>/dev/null; then require_space_mb /opt 10240 fi SERVER_PUBLIC_IP="$(detect_public_ipv4)" || \ die "não foi possível detectar IP público IPv4 (todos os provedores falharam: ipify, amazonaws, icanhazip, ifconfig.me)" log " ✓ IP público IPv4 detectado: $SERVER_PUBLIC_IP" # A confirmação humana continua obrigatória, mas este aviso detecta o caso # mais comum de imagem cloud ainda usando DHCP no perfil ativo. # ipv4.method é propriedade de UM perfil: no modo lista (`connection show # --active`) o campo é inválido e o nmcli falha em silêncio — por isso a # consulta é feita perfil a perfil, pelo UUID. if command -v nmcli >/dev/null 2>&1; then local nm_uuid nm_method while IFS= read -r nm_uuid; do [[ -n "$nm_uuid" ]] || continue nm_method="$(nmcli -g ipv4.method connection show "$nm_uuid" 2>/dev/null || true)" if [[ "$nm_method" == "auto" ]]; then warn "NetworkManager reporta ipv4.method=auto em conexão ativa" warn " Confirme no provedor que o IPv4 é realmente estático/reservado antes de licenciar" break fi done < <(nmcli -t -g UUID connection show --active 2>/dev/null || true) fi local dns_ips dns_ips="$(resolve_hostname_ipv4s "$HOSTNAME_FQDN")" if [[ -z "$dns_ips" ]]; then if [[ "$ALLOW_DNS_MISMATCH" == "1" ]]; then warn "HOSTNAME_FQDN='$HOSTNAME_FQDN' ainda não resolve para registro A" warn " A instalação continuará; o cPanel poderá usar um hostname provisório *.cprapid.com" warn " Após publicar ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP}, a reconciliação será automática" return 0 fi die "HOSTNAME_FQDN='$HOSTNAME_FQDN' não resolve para nenhum registro A (modo estrito: ALLOW_DNS_MISMATCH=0)" fi if [[ "$dns_ips" != "$SERVER_PUBLIC_IP" ]]; then if [[ "$ALLOW_DNS_MISMATCH" == "1" ]]; then warn "DNS do hostname não bate com o IP público detectado" warn " Hostname resolve para: $(echo "$dns_ips" | tr '\n' ' ')" warn " IP público detectado: $SERVER_PUBLIC_IP" warn " A instalação continuará com DNS pendente; o hostname desejado será reconciliado depois" else die "DNS do hostname deve apontar para $SERVER_PUBLIC_IP (atual: $(echo "$dns_ips" | tr '\n' ' '))" fi else log " ✓ DNS do hostname aponta para o IP público" fi } run_preflight_only() { [[ $EUID -eq 0 ]] || die "este script precisa ser executado como root" umask 077 load_runtime_config_phase1 collect_interactive_config validate_config run_preflight normalize_tmp_size_mb log "PREFLIGHT APROVADO — nenhuma instalação ou alteração persistente foi executada" printf ' Hostname : %s\n' "$HOSTNAME_FQDN" printf ' IPv4 : %s\n' "$SERVER_PUBLIC_IP" printf ' Tier : %s\n' "$CPANEL_TIER" printf ' Antivírus: %s\n' "$ANTIVIRUS" printf ' SSH : %s (porta 22 mantida: %s)\n' "$SSH_PORT" "$KEEP_SSH_PORT_22_OPEN" printf ' Slack : %s\n' "$([[ -n "$NOTIFY_WEBHOOK_URL" ]] && echo configurado || echo desativado)" printf ' /tmp alvo: %s MB\n' "$TMP_SIZE_MB" } ensure_hosts_entry() { local host_ip="$1" hosts_file="${2:-/etc/hosts}" hosts_dir local short_host tmp_hosts tmp_hosts_new is_ipv4 "$host_ip" || return 1 [[ -f "$hosts_file" && ! -L "$hosts_file" ]] || return 1 hosts_dir="$(dirname "$hosts_file")" short_host="${HOSTNAME_FQDN%%.*}" # Ambos os temporários ficam no mesmo diretório do alvo para promoção # segura. Em produção é /etc; o parâmetro opcional existe para regressões. tmp_hosts="$(mktemp "${hosts_dir}/.hosts.cpanel-bootstrap.XXXXXX")" || return 1 tmp_hosts_new="$(mktemp "${hosts_dir}/.hosts.cpanel-bootstrap.XXXXXX")" || { rm -f "$tmp_hosts" return 1 } cp -a "$hosts_file" "${hosts_file}.bak.$(date +%Y%m%d%H%M%S)" || { rm -f "$tmp_hosts" "$tmp_hosts_new" return 1 } # Remove o FQDN/short de qualquer IP que não seja o alvo. Isso evita que # uma entrada antiga de migração/NAT apareça primeiro no getent e mascare # o mapeamento canônico que acabamos de confirmar. awk -v target_ip="$host_ip" -v fqdn="$HOSTNAME_FQDN" -v short="$short_host" ' NF == 0 || $1 ~ /^#/ { print; next } $1 != target_ip { line=$1 kept_name=0 comment="" for (i=2; i<=NF; i++) { if ($i ~ /^#/) { comment=$i for (j=i+1; j<=NF; j++) comment=comment " " $j break } if ($i != fqdn && $i != short) { line=line " " $i kept_name=1 } } if (kept_name) { if (comment != "") line=line " " comment print line } else if (comment != "") { print comment } next } { print } ' "$hosts_file" > "$tmp_hosts" || { rm -f "$tmp_hosts" "$tmp_hosts_new" return 1 } # O primeiro nome após o IP é o canônico para getent/hostname -f. O cPanel # pode inserir .cprapid.com nessa posição; apenas anexar nosso FQDN ao # fim não corrige a resolução canônica. Consolida as linhas deste IP, # coloca o hostname esperado primeiro e preserva aliases distintos depois. awk -v ip="$host_ip" -v fqdn="$HOSTNAME_FQDN" -v short="$short_host" ' $1 == ip { found_ip=1 for (i=2; i<=NF; i++) { if ($i ~ /^#/) break if ($i != fqdn && $i != short && !seen[$i]++) { aliases[++alias_count]=$i } } next } { print } END { line=ip " " fqdn " " short for (i=1; i<=alias_count; i++) line=line " " aliases[i] print line } ' "$tmp_hosts" > "$tmp_hosts_new" || { rm -f "$tmp_hosts" "$tmp_hosts_new" return 1 } install -m 0644 "$tmp_hosts_new" "$hosts_file" || { rm -f "$tmp_hosts" "$tmp_hosts_new" return 1 } rm -f "$tmp_hosts" "$tmp_hosts_new" || return 1 } expected_hosts_entry_matches() { local expected_ip="$1" hosts_file="${2:-/etc/hosts}" local short_host="${HOSTNAME_FQDN%%.*}" is_ipv4 "$expected_ip" || return 1 [[ -f "$hosts_file" && ! -L "$hosts_file" ]] || return 1 awk -v ip="$expected_ip" -v fqdn="$HOSTNAME_FQDN" -v short="$short_host" ' NF == 0 || $1 ~ /^#/ { next } $1 == ip { target_lines++ if ($2 == fqdn && $3 == short) canonical++ next } { for (i=2; i<=NF; i++) { if ($i ~ /^#/) break if ($i == fqdn || $i == short) conflict=1 } } END { exit !(target_lines == 1 && canonical == 1 && conflict == 0) } ' "$hosts_file" } # Em clouds com NAT 1:1 (GCP, OCI, Azure, alguns OpenStack/Proxmox) o IP # público não está atribuído a nenhuma interface local — nesse caso o # /etc/hosts deve usar o IP primário local (o cPanel detecta o NAT e gerencia # o mapeamento em /var/cpanel/cpnat). select_hosts_ip() { local primary_ip if ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); print a[1]}' | grep -qxF "$SERVER_PUBLIC_IP"; then printf '%s\n' "$SERVER_PUBLIC_IP" return 0 fi primary_ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')" if is_ipv4 "$primary_ip"; then # >&2: esta função roda dentro de $( ) — warnings em stdout seriam # capturados junto com o IP e corromperiam o /etc/hosts warn "IP público $SERVER_PUBLIC_IP não está em nenhuma interface local (NAT 1:1 do provedor)" >&2 warn " /etc/hosts usará o IP local $primary_ip; o cPanel gerencia o NAT em /var/cpanel/cpnat" >&2 printf '%s\n' "$primary_ip" else printf '%s\n' "$SERVER_PUBLIC_IP" fi } configure_cloud_init_hostname_persistence() { local cloud_dir="/etc/cloud/cloud.cfg.d" local file="${cloud_dir}/99-cpanel-bootstrap-hostname.cfg" local tmp # O cloud-init pode reaplicar o hostname da imagem/metadados no reboot que # separa as duas fases. Só cria o override quando o host realmente usa # cloud-init, sem introduzir /etc/cloud em instalações tradicionais. if ! command -v cloud-init >/dev/null 2>&1 && \ [[ ! -f /etc/cloud/cloud.cfg && ! -d "$cloud_dir" ]]; then return 0 fi install -d -m 0755 "$cloud_dir" || return 1 if [[ -e "$file" && ( ! -f "$file" || -L "$file" ) ]]; then warn "${file} não é um arquivo regular seguro" return 1 fi tmp="$(mktemp "${cloud_dir}/.hostname.cpanel-bootstrap.XXXXXX")" || return 1 if ! printf '%s\n' 'preserve_hostname: true' > "$tmp"; then rm -f "$tmp" return 1 fi chmod 0644 "$tmp" || { rm -f "$tmp"; return 1; } mv -f "$tmp" "$file" || { rm -f "$tmp"; return 1; } grep -qxF 'preserve_hostname: true' "$file" || return 1 log " ✓ cloud-init configurado para preservar o hostname" } enforce_expected_hostname() { local current_hostname current_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || true)" if [[ "$current_hostname" != "$HOSTNAME_FQDN" ]]; then warn "Hostname atual '${current_hostname:-desconhecido}' difere do configurado; reaplicando $HOSTNAME_FQDN" hostnamectl set-hostname "$HOSTNAME_FQDN" || return 1 fi current_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || true)" [[ "$current_hostname" == "$HOSTNAME_FQDN" ]] || { warn "kernel ainda reporta hostname '${current_hostname:-desconhecido}'" return 1 } ensure_hosts_entry "$(select_hosts_ip)" || return 1 expected_hostname_state_matches || { warn "hostname não ficou consistente: $(hostname_state_details)" return 1 } log " ✓ Hostname confirmado: ${HOSTNAME_FQDN}" } expected_hostname_state_matches() { local kernel_hostname static_hostname file_hostname hosts_expected_ip api_json kernel_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || true)" static_hostname="$(hostnamectl --static 2>/dev/null || true)" file_hostname="$(head -n 1 /etc/hostname 2>/dev/null | tr -d '[:space:]' || true)" hosts_expected_ip="$(select_hosts_ip)" || return 1 [[ "$kernel_hostname" == "$HOSTNAME_FQDN" ]] || return 1 [[ "$static_hostname" == "$HOSTNAME_FQDN" ]] || return 1 [[ "$file_hostname" == "$HOSTNAME_FQDN" ]] || return 1 expected_hosts_entry_matches "$hosts_expected_ip" || return 1 # Sem licença a API inteira fica indisponível; nesse caso as verificações # de kernel/arquivo/hosts continuam válidas e o --finish confirma a API. api_json="$(whmapi_json_success gethostname 2>/dev/null || true)" if [[ -n "$api_json" ]]; then jq -e --arg expected "$HOSTNAME_FQDN" \ '.data.hostname == $expected' >/dev/null 2>&1 <<<"$api_json" || return 1 fi } hostname_state_details() { local kernel_hostname static_hostname file_hostname hosts_expected_ip local hosts_line hosts_local_state api_json api_hostname kernel_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo n/d)" static_hostname="$(hostnamectl --static 2>/dev/null || echo n/d)" file_hostname="$(head -n 1 /etc/hostname 2>/dev/null | tr -d '[:space:]' || echo n/d)" hosts_expected_ip="$(select_hosts_ip 2>/dev/null || echo n/d)" hosts_line="$(awk -v ip="$hosts_expected_ip" '$1 == ip { print $1 "/" $2; exit }' /etc/hosts 2>/dev/null || true)" if expected_hosts_entry_matches "$hosts_expected_ip"; then hosts_local_state="ok" else hosts_local_state="divergente" fi [[ -n "$hosts_line" ]] || hosts_line="n/d" api_json="$(whmapi_json_success gethostname 2>/dev/null || true)" api_hostname="$(jq -r '.data.hostname // empty' <<<"$api_json" 2>/dev/null || true)" [[ -n "$api_hostname" ]] || api_hostname="indisponível" printf 'kernel=%s, static=%s, /etc/hostname=%s, hosts=%s (esperado %s; local %s), API=%s' \ "$kernel_hostname" "$static_hostname" "$file_hostname" "$hosts_line" \ "$hosts_expected_ip" "$hosts_local_state" "$api_hostname" } # Retornos: 0 = reconciliado; 1 = falha real; 2 = DNS ainda pendente e o # hostname provisório do cPanel deve ser mantido até a próxima tentativa. reconcile_cpanel_hostname() { local hosts_ip dns_gate_rc=0 set_hostname_rc=0 actual_before local reconcile_log="${LOG_DIR}/hostname-reconcile.log" cpanel_files_present || { warn "runtime do cPanel ainda não possui o utilitário de hostname" return 1 } actual_before="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo desconhecido)" hosts_ip="$(select_hosts_ip)" || return 1 # Se o kernel já usa o FQDN desejado, é seguro reparar /etc/hosts e # confirmar os demais estados mesmo antes da publicação do DNS. Quando o # cPanel usa um hostname provisório, porém, mantemos /etc/hosts coerente com # ele até o registro A estar pronto e o setter oficial executar a troca. if [[ "$actual_before" == "$HOSTNAME_FQDN" ]]; then ensure_hosts_entry "$hosts_ip" || return 1 if expected_hostname_state_matches; then configure_cloud_init_hostname_persistence || return 1 rm -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" log " ✓ Hostname do cPanel já está consistente: ${HOSTNAME_FQDN}" return 0 fi fi { printf 'required_at=%s\n' "$(date --iso-8601=seconds)" printf 'expected=%s\n' "$HOSTNAME_FQDN" printf 'observed=%s\n' "$actual_before" } > "$HOSTNAME_RECONCILE_REQUIRED_MARKER" || return 1 chmod 0600 "$HOSTNAME_RECONCILE_REQUIRED_MARKER" || return 1 validate_cpanel_hostname_dns_gate 3 5 || dns_gate_rc=$? if (( dns_gate_rc != 0 )); then { printf 'dns_status=%s\n' "$CPANEL_HOSTNAME_DNS_STATUS" printf 'dns_ipv4=%s\n' "$CPANEL_HOSTNAME_DNS_IPV4" } >> "$HOSTNAME_RECONCILE_REQUIRED_MARKER" || return 1 if (( dns_gate_rc == 3 )) && [[ "$ALLOW_DNS_MISMATCH" == "1" ]]; then if ! is_expected_or_cprapid_hostname "$actual_before"; then warn "hostname divergente inesperado '${actual_before}'; somente ${HOSTNAME_FQDN} ou *.cprapid.com pode aguardar DNS automaticamente" return 1 fi warn "DNS de ${HOSTNAME_FQDN} ainda não aponta para ${SERVER_PUBLIC_IP}" if [[ -n "$CPANEL_HOSTNAME_DNS_IPV4" ]]; then warn " Resposta atual: A=${CPANEL_HOSTNAME_DNS_IPV4} — o hostname precisa de EXATAMENTE um A (${SERVER_PUBLIC_IP}); remova registros antigos/extras" fi warn " Mantendo o hostname provisório '${actual_before}' e deixando a reconciliação pendente" warn " O watcher tentará novamente a cada 30 min; para antecipar: ${INSTALL_PATH} --finish" return 2 fi warn "não é seguro restaurar o hostname enquanto o resolver local não confirmar ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP} (status=${CPANEL_HOSTNAME_DNS_STATUS})" return 1 fi touch "$reconcile_log" || return 1 chmod 0600 "$reconcile_log" || return 1 printf '\n[%s] Alterando hostname oficialmente: %s → %s\n' \ "$(date '+%F %T')" "$actual_before" "$HOSTNAME_FQDN" >> "$reconcile_log" timeout --signal=TERM --kill-after=60s 900 \ /usr/local/cpanel/bin/set_hostname "$HOSTNAME_FQDN" \ >>"$reconcile_log" 2>&1 || set_hostname_rc=$? if (( set_hostname_rc != 0 )); then warn "set_hostname retornou exit ${set_hostname_rc}; veja ${reconcile_log}" return 1 fi configure_cloud_init_hostname_persistence || return 1 ensure_hosts_entry "$hosts_ip" || return 1 if ! expected_hostname_state_matches; then warn "set_hostname terminou, mas o estado ainda diverge: $(hostname_state_details)" return 1 fi { printf 'changed_at=%s\n' "$(date --iso-8601=seconds)" printf 'from=%s\n' "$actual_before" printf 'to=%s\n' "$HOSTNAME_FQDN" } > "$HOSTNAME_RECONCILE_CHANGED_MARKER" || return 1 chmod 0600 "$HOSTNAME_RECONCILE_CHANGED_MARKER" || return 1 rm -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" # Certificados/status obtidos para o hostname automático não podem ser # reaproveitados. O passo AutoSSL será repetido ainda nesta execução. rm -f "${STEP_DIR}/autossl.done" "$HOSTNAME_CERT_STATUS_PATH" \ "$HOSTNAME_CERT_ATTEMPT_PATH" "$HOSTNAME_CERT_PENDING_MARKER" log " ✓ Hostname restaurado oficialmente: ${HOSTNAME_FQDN}" } normalize_tmp_size_mb() { if [[ -z "${TMP_SIZE_MB:-}" ]]; then local disk_total_gb disk_total_gb="$(df -BG --output=size / | awk 'NR==2 {gsub(/[G ]/,"",$1); print $1}')" if ! is_integer "$disk_total_gb"; then warn "Não foi possível detectar tamanho do disco — usando 8 GB por segurança" TMP_SIZE_MB="8192" elif (( disk_total_gb < 100 )); then TMP_SIZE_MB="8192" log "Disco / detectado: ${disk_total_gb} GB (< 100 GB) → /tmp alvo = 8 GB" else TMP_SIZE_MB="$CPANEL_MAX_TMP_SIZE_MB" log "Disco / detectado: ${disk_total_gb} GB (>= 100 GB) → /tmp alvo = 10 GB" fi else log "TMP_SIZE_MB forçado no cabeçalho: ${TMP_SIZE_MB} MB" fi if (( TMP_SIZE_MB < CPANEL_MIN_TMP_SIZE_MB )); then warn "TMP_SIZE_MB=${TMP_SIZE_MB} abaixo do mínimo ${CPANEL_MIN_TMP_SIZE_MB}; ajustando" TMP_SIZE_MB="$CPANEL_MIN_TMP_SIZE_MB" elif (( TMP_SIZE_MB > CPANEL_MAX_TMP_SIZE_MB )); then warn "TMP_SIZE_MB=${TMP_SIZE_MB} acima do máximo aceito pelo cPanel (${CPANEL_MAX_TMP_SIZE_MB}); ajustando" TMP_SIZE_MB="$CPANEL_MAX_TMP_SIZE_MB" fi } calculate_php_fpm_max_children() { if [[ -n "$PHP_FPM_MAX_CHILDREN" ]]; then printf '%s\n' "$PHP_FPM_MAX_CHILDREN" return 0 fi local mem_mb mem_mb="$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo)" if ! is_integer "$mem_mb"; then printf '8\n' elif (( mem_mb < 4096 )); then printf '5\n' elif (( mem_mb < 8192 )); then printf '8\n' elif (( mem_mb < 16384 )); then printf '12\n' else printf '20\n' fi } ensure_timezone_available() { local zoneinfo_path="/usr/share/zoneinfo/${TIMEZONE}" if [[ -f "$zoneinfo_path" ]]; then return 0 fi log "Timezone $TIMEZONE não encontrado em /usr/share/zoneinfo — instalando/atualizando tzdata" yum install -y tzdata [[ -f "$zoneinfo_path" ]] || \ die "TIMEZONE='$TIMEZONE' não encontrado mesmo após instalar tzdata" } # Garante NTP contínuo via chrony — vários templates cloud vêm sem ele, e # relógio errado quebra licença cPanel, TLS e DKIM. ensure_time_sync() { log "Garantindo sincronização contínua de horário (chrony)" # Em VMs VMware (ex.: OVH VPS), o open-vm-tools sincroniza o relógio com # o HOST e briga com o chrony — em teste real isso gerou saltos de ±23min # ("Forward time jump detected") que MATARAM serviços no meio da fase 2. # O NTP do chrony é a fonte correta; desligamos o timesync do hypervisor. if command -v vmware-toolbox-cmd >/dev/null 2>&1; then if vmware-toolbox-cmd timesync status 2>/dev/null | grep -qi enabled; then if vmware-toolbox-cmd timesync disable >/dev/null 2>&1; then log " ✓ Timesync do host VMware desabilitado (chrony assume o relógio)" else warn " não foi possível desabilitar o timesync do VMware — risco de saltos de relógio" fi fi fi rpm -q chrony >/dev/null 2>&1 || yum install -y chrony # makestep a QUALQUER momento (default: só nos 3 primeiros updates). # A sincronização de INICIALIZAÇÃO do VMware é flag do lado do HOST # (time.synchronize.tools.startup) — incontornável no guest. Se o host # estiver com hora errada, ele crava a hora no boot; com isto o chrony # corrige em segundos, em vez de deixar o relógio errado escorregando. if grep -qE '^makestep' /etc/chrony.conf 2>/dev/null; then sed -i -E 's|^makestep.*|makestep 1.0 -1|' /etc/chrony.conf else echo 'makestep 1.0 -1' >> /etc/chrony.conf fi if systemctl enable --now chronyd >/dev/null 2>&1; then systemctl try-restart chronyd >/dev/null 2>&1 || true timedatectl set-ntp true 2>/dev/null || true chronyc makestep >/dev/null 2>&1 || true # Espera a sincronização REAL antes de prosseguir — evita gravar o # hwclock com hora errada e saltos de relógio pós-reboot (até ~2 min) if chronyc waitsync 12 0.5 >/dev/null 2>&1; then log " ✓ chronyd ativo e SINCRONIZADO (offset < 0.5s)" else warn " chronyd ativo mas ainda não sincronizou totalmente — seguindo" fi else warn "não foi possível habilitar chronyd — verifique o NTP manualmente" fi } # Cria swapfile se o servidor não tiver swap (cloud images costumam vir sem). # Sem swap, picos de ClamAV/SpamAssassin/mysqld disparam o OOM-killer. ensure_swap() { [[ "$SWAP_ENABLE" == "1" ]] || return 0 if swapon --show --noheadings 2>/dev/null | grep -q .; then # A coluna vai em --show=NAME (a flag --output não existe aqui; o # getopt a abreviaria para --output-all e o grep exato nunca casaria). if swapon --show=NAME --noheadings 2>/dev/null | grep -qxF /swapfile; then grep -qE '^/swapfile[[:space:]]' /etc/fstab || \ echo '/swapfile none swap defaults 0 0' >> /etc/fstab fi log " ✓ Swap já ativo — mantendo o existente" return 0 fi local mem_mb swap_mb avail_mb mem_mb="$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo)" if ! is_integer "$mem_mb" || (( mem_mb < 4096 )); then swap_mb=2048 else swap_mb=4096 fi avail_mb="$(df -Pm / | awk 'NR==2 {print $4}')" if ! is_integer "$avail_mb" || (( avail_mb < swap_mb + MIN_ROOT_FREE_MB )); then warn "Espaço insuficiente em / para swapfile de ${swap_mb} MB — seguindo sem swap" return 0 fi log "Sem swap detectado — criando swapfile de ${swap_mb} MB em /swapfile" if [[ -L /swapfile ]]; then die "/swapfile é symlink — recusando sobrescrever caminho inseguro" elif [[ -e /swapfile ]]; then die "/swapfile já existe mas não está ativo; revise/remova manualmente antes de continuar" fi if ! dd if=/dev/zero of=/swapfile bs=1M count="$swap_mb" status=none 2>/dev/null; then warn "falha ao criar /swapfile — seguindo sem swap" rm -f /swapfile return 0 fi chmod 600 /swapfile if mkswap /swapfile >/dev/null 2>&1 && swapon /swapfile 2>/dev/null; then grep -qE '^/swapfile[[:space:]]' /etc/fstab || \ echo '/swapfile none swap defaults 0 0' >> /etc/fstab log " ✓ Swap de ${swap_mb} MB ativo e persistente no /etc/fstab" else warn "mkswap/swapon falhou — removendo /swapfile e seguindo sem swap" swapoff /swapfile 2>/dev/null || true rm -f /swapfile fi } # ----- firewall de transição (nftables) -------------------------------------- # A fase 1 desliga o firewalld e o CSF só assume no fim da fase 2 — sem isso, # o servidor ficaria 30-90+ min exposto enquanto o instalador do cPanel sobe # Exim/Dovecot/MySQL/cpsrvd com config de fábrica. Este ruleset mínimo aceita # SSH (22 + SSH_PORT), ICMP, conexões estabelecidas e WHM/2087 somente dos IPs # de gestão conhecidos. Ele é removido apenas DEPOIS que CSF/LFD são validados. setup_gap_firewall() { log "Ativando firewall mínimo de transição (nftables) até o CSF assumir na fase 2" if ! yum install -y nftables >/dev/null 2>&1; then die "não foi possível instalar nftables; recusando prosseguir sem firewall de transição" fi local ssh_ports="22" admin_ip admin_whm_sources="" admin_whm_rule="" if [[ "$SSH_PORT" != "22" ]]; then ssh_ports="22, ${SSH_PORT}" fi for admin_ip in ${ADMIN_SRC_IP:-} ${ADMIN_ALLOW_IPS:-}; do is_ipv4 "$admin_ip" || continue if [[ ",${admin_whm_sources// /}," != *",${admin_ip},"* ]]; then admin_whm_sources+="${admin_whm_sources:+, }${admin_ip}" fi done if [[ -n "$admin_whm_sources" ]]; then admin_whm_rule=" ip saddr { ${admin_whm_sources} } tcp dport 2087 accept" fi install -d -m 0755 /etc/nftables cat > "$GAP_FIREWALL_RULES_PATH" < "$check_rules" if ! nft -c -f "$check_rules"; then rm -f "$check_rules" die "ruleset nftables de transição reprovado no nft -c" fi rm -f "$check_rules" cat > "$GAP_FIREWALL_SERVICE_PATH" </dev/null 2>&1 || \ die "unit iniciou, mas a tabela inet cpanel_bootstrap não está carregada" touch "${STATE_DIR}/gap-firewall.active" if [[ -n "$admin_whm_sources" ]]; then log " ✓ firewall dedicado ativo: SSH (${ssh_ports}); WHM/2087 somente de ${admin_whm_sources}; ICMP/estabelecidas" else log " ✓ firewall dedicado ativo: SSH (${ssh_ports}), ICMP e conexões estabelecidas; WHM/2087 bloqueado externamente até o CSF" fi } teardown_gap_firewall() { [[ -f "${STATE_DIR}/gap-firewall.active" ]] || return 0 log "Removendo firewall de transição (nftables) — o CSF assume a partir de agora" if systemctl is-active --quiet cpanel-bootstrap-firewall.service 2>/dev/null; then systemctl stop cpanel-bootstrap-firewall.service >/dev/null 2>&1 || return 1 fi if nft list table inet cpanel_bootstrap >/dev/null 2>&1; then nft delete table inet cpanel_bootstrap 2>/dev/null || return 1 fi nft list table inet cpanel_bootstrap >/dev/null 2>&1 && return 1 # Marker sai IMEDIATAMENTE após confirmar a tabela ausente: se o processo # morrer entre o stop e este ponto, marker órfão + CSF ativo fariam o # ensure_gap_firewall_still_active reaplicar a tabela drop por cima de um # servidor quase pronto na retentativa seguinte. rm -f "${STATE_DIR}/gap-firewall.active" systemctl disable cpanel-bootstrap-firewall.service >/dev/null 2>&1 || \ warn "não foi possível desabilitar a unit já parada do firewall transitório" rm -f "$GAP_FIREWALL_RULES_PATH" "$GAP_FIREWALL_SERVICE_PATH" log " ✓ tabela temporária removida; demais regras nftables foram preservadas" } # A fase 2 abre uma janela de 30-90 min (instalador) protegida apenas pela # tabela de transição. Se ela sumiu entre o reboot e esta tentativa (unit que # não subiu, flush de outro componente), prosseguir seria fail-open silencioso # — reaplica e confirma antes de qualquer trabalho. ensure_gap_firewall_still_active() { # Ausência do marker = o CSF já assumiu e o gap foi removido de propósito. [[ -f "${STATE_DIR}/gap-firewall.active" ]] || return 0 # CSF validado e ativo com marker sobrando = teardown morto entre o stop # e o rm do marker. Reaplicar a tabela drop aqui derrubaria HTTP/mail de # um servidor quase pronto — completa a limpeza em vez de reaplicar. if [[ -f "${STEP_DIR}/csf_config.done" ]] && \ systemctl is-active --quiet lfd 2>/dev/null && \ grep -qE '^TESTING = "0"' "$CSF_CONF" 2>/dev/null; then rm -f "${STATE_DIR}/gap-firewall.active" log " ✓ CSF já assumiu o firewall; marker residual da transição removido" return 0 fi if nft list table inet cpanel_bootstrap >/dev/null 2>&1; then return 0 fi warn "tabela nftables de transição ausente neste boot — reaplicando antes de prosseguir" [[ -f "$GAP_FIREWALL_RULES_PATH" ]] || \ die "firewall de transição sumiu e ${GAP_FIREWALL_RULES_PATH} não existe; execute a fase 1 novamente ou investigue" systemctl restart cpanel-bootstrap-firewall.service >/dev/null 2>&1 || \ nft -f "$GAP_FIREWALL_RULES_PATH" >/dev/null 2>&1 || true nft list table inet cpanel_bootstrap >/dev/null 2>&1 || \ die "não foi possível reaplicar o firewall de transição; recusando prosseguir em fail-open" log " ✓ firewall de transição reaplicado e confirmado" } ensure_epel_ready() { # O `latest` é o dono da instalação do EPEL. Não pinamos baseurl, não # removemos metalink e não o desabilitamos: o cPanel requer o repositório. rpm -q epel-release >/dev/null 2>&1 || return 1 if ! dnf -q repolist --enabled 2>/dev/null | awk '{print $1}' | grep -qx epel; then dnf config-manager --set-enabled epel \ >>"${LOG_DIR}/epel-postinstall.log" 2>&1 || return 1 fi dnf -q --disablerepo='*' --enablerepo=epel makecache \ >>"${LOG_DIR}/epel-postinstall.log" 2>&1 } write_cpupdate_config() { local target="${1:-/etc/cpupdate.conf}" target_dir tmp log "Configurando tier do cPanel: $CPANEL_TIER" target_dir="$(dirname "$target")" [[ ! -e "$target" || ( -f "$target" && ! -L "$target" ) ]] || { warn "${target} não é um arquivo regular seguro" return 1 } tmp="$(mktemp "${target_dir}/.cpupdate.conf.cpanel-bootstrap.XXXXXX")" || return 1 # O cPanel pode introduzir novas chaves entre releases. Administramos # somente estas quatro e preservamos todo o restante do arquivo. if [[ -f "$target" ]]; then awk '!/^[[:space:]]*(CPANEL|RPMUP|SARULESUP|UPDATES)[[:space:]]*=/' \ "$target" > "$tmp" || { rm -f "$tmp"; return 1; } fi cat >> "$tmp" < 0' "$EA4_PROFILE_PATH" >/dev/null 2>&1 || \ die "perfil EA4 inválido: JSON precisa conter array .pkgs não vazio" jq -e '.pkgs | index("ea-apache24-mod_mpm_event") != null' "$EA4_PROFILE_PATH" >/dev/null 2>&1 || \ die "perfil EA4 precisa incluir ea-apache24-mod_mpm_event" jq -e '.pkgs | index("ea-apache24-mod_proxy_fcgi") != null' "$EA4_PROFILE_PATH" >/dev/null 2>&1 || \ die "perfil EA4 precisa incluir ea-apache24-mod_proxy_fcgi" jq -e '.pkgs | index("ea-apache24-mod_cgid") != null' "$EA4_PROFILE_PATH" >/dev/null 2>&1 || \ die "perfil EA4 precisa incluir ea-apache24-mod_cgid" jq -e '.pkgs | index("ea-apache24-mod_http2") != null' "$EA4_PROFILE_PATH" >/dev/null 2>&1 || \ die "perfil EA4 precisa incluir ea-apache24-mod_http2" jq -e '.pkgs | any(test("^ea-php[0-9]+-php-fpm$"))' "$EA4_PROFILE_PATH" >/dev/null 2>&1 || \ die "perfil EA4 precisa incluir PHP-FPM para ao menos uma versão EA-PHP" if jq -e '.pkgs | (index("ea-apache24-mod_mpm_prefork") != null or index("ea-apache24-mod_ruid2") != null)' \ "$EA4_PROFILE_PATH" >/dev/null 2>&1; then die "perfil EA4 event não pode conter mpm_prefork/mod_ruid2" fi install -m 0600 "$EA4_PROFILE_PATH" "$EA4_INITIAL_PROFILE" log " ✓ Perfil EA4 inicial validado e instalado em $EA4_INITIAL_PROFILE" } # ----- SSH: chave do admin + hardening --------------------------------------- install_admin_ssh_key() { [[ -n "$ADMIN_SSH_PUBKEY" ]] || return 0 log "Instalando chave pública SSH do administrador em /root/.ssh/authorized_keys" install -d -m 0700 /root/.ssh touch /root/.ssh/authorized_keys chmod 0600 /root/.ssh/authorized_keys if ! grep -qxF "$ADMIN_SSH_PUBKEY" /root/.ssh/authorized_keys; then echo "$ADMIN_SSH_PUBKEY" >> /root/.ssh/authorized_keys log " ✓ Chave adicionada" else log " ✓ Chave já presente" fi } # Hardening do sshd via drop-in. O nome 01- ordena antes dos drop-ins padrão # (50-redhat.conf, cloud-init) e no OpenSSH a PRIMEIRA ocorrência de cada # diretiva vence — então nossos valores têm prioridade. harden_sshd() { log "Aplicando hardening do sshd (drop-in 01-cpanel-bootstrap.conf)" install -d -m 0755 /etc/ssh/sshd_config.d local hardening_file="/etc/ssh/sshd_config.d/01-cpanel-bootstrap.conf" local hardening_backup="${STATE_DIR}/ssh-hardening.previous" if [[ -f "$hardening_file" ]]; then cp -a "$hardening_file" "$hardening_backup" else rm -f "$hardening_backup" fi { echo "# Gerado por cpanel-bootstrap.sh — hardening base do sshd" echo "MaxAuthTries 3" echo "LoginGraceTime 30" echo "MaxStartups 10:30:60" echo "PermitEmptyPasswords no" echo "X11Forwarding no" if [[ -n "$ADMIN_SSH_PUBKEY" ]]; then # Só restringe root a chave quando o bootstrap instalou uma chave # explicitamente — nunca com base em authorized_keys pré-existente # que o operador pode não controlar. echo "PermitRootLogin prohibit-password" if [[ "$SSH_DISABLE_PASSWORD_AUTH" == "1" ]]; then echo "PasswordAuthentication no" fi fi } > "$hardening_file" if sshd -t 2>/dev/null; then if ! systemctl reload sshd; then warn "reload do sshd falhou — restaurando o drop-in anterior" if [[ -f "$hardening_backup" ]]; then cp -a "$hardening_backup" "$hardening_file" else rm -f "$hardening_file" fi sshd -t 2>/dev/null || die "configuração anterior do sshd também está inválida; use o console" if ! systemctl reload sshd; then die "não foi possível recarregar o sshd após rollback; use o console" fi die "hardening do sshd não foi aplicado; configuração anterior restaurada" fi if [[ -n "$ADMIN_SSH_PUBKEY" ]]; then log " ✓ sshd endurecido (MaxAuthTries 3, root apenas com chave)" else log " ✓ sshd endurecido (MaxAuthTries 3, LoginGraceTime 30)" fi else warn "sshd -t reprovou o drop-in de hardening — restaurando a configuração anterior" if [[ -f "$hardening_backup" ]]; then cp -a "$hardening_backup" "$hardening_file" else rm -f "$hardening_file" fi sshd -t 2>/dev/null || die "configuração anterior do sshd também está inválida; use o console" die "hardening do sshd não foi aplicado; configuração anterior restaurada" fi rm -f "$hardening_backup" } # ----- helpers do CSF --------------------------------------------------------- # Ajusta um valor escalar no csf.conf csf_set() { local key="$1" value="$2" if grep -qE "^${key} = " "$CSF_CONF"; then sed -i -E "s|^${key} = \"[^\"]*\"|${key} = \"${value}\"|" "$CSF_CONF" log " ✓ ${key} = ${value}" else warn " ✗ ${key} não encontrado em csf.conf" fi } # Adiciona um valor a uma lista de portas do csf.conf (TCP_IN, TCP6_IN, ...). # O firewall precisa ser consistente em IPv4 E IPv6: editar só o TCP_IN # deixaria a porta 22 aberta via IPv6 e a porta nova bloqueada via IPv6. csf_list_add() { local list="$1" value="$2" label="$3" current if ! grep -qE "^${list} = " "$CSF_CONF"; then warn " ✗ ${list} não encontrado em csf.conf — pulando ${label}" return 0 fi current="$(sed -nE "s/^${list} = \"([^\"]*)\".*/\1/p" "$CSF_CONF" | head -1)" if [[ -z "$current" ]]; then sed -i -E "s|^${list} = \"[^\"]*\"|${list} = \"${value}\"|" "$CSF_CONF" log " ✓ ${list}: ${label} adicionado (lista estava vazia)" elif [[ ",${current}," == *",${value},"* ]]; then log " ✓ ${list}: ${label} já presente" else sed -i -E "s/^(${list} = \"[^\"]*)(\")/\1,${value}\2/" "$CSF_CONF" log " ✓ ${list}: ${label} adicionado" fi } csf_list_remove() { local list="$1" value="$2" label="$3" current new current="$(sed -nE "s/^${list} = \"([^\"]*)\".*/\1/p" "$CSF_CONF" | head -1)" if [[ ",${current}," == *",${value},"* ]]; then new="$(awk -v val="$value" 'BEGIN{FS=OFS=","} { out="" for (i=1; i<=NF; i++) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $i) if ($i != val && $i != "") out = out ? out OFS $i : $i } print out }' <<<"$current")" sed -i -E "s|^${list} = \"[^\"]*\"|${list} = \"${new}\"|" "$CSF_CONF" log " ✓ ${list}: ${label} removido" fi } csf_list_contains() { local list="$1" value="$2" sed -nE "s/^${list} = \"([^\"]*)\".*/\1/p" "$CSF_CONF" | \ tr ',' '\n' | awk '{$1=$1; print}' | grep -qxF "$value" } # Whitelist anti-lockout: garante os IPs de gestão no csf.allow (nunca # bloqueados pelo firewall) e no csf.ignore (ignorados pelo lfd). csf_whitelist_admin_ips() { local ip entry rc=0 for ip in ${ADMIN_SRC_IP:-} ${ADMIN_ALLOW_IPS:-}; do is_ipv4 "$ip" || continue for entry in /etc/csf/csf.allow /etc/csf/csf.ignore; do if [[ ! -f "$entry" ]]; then warn " ✗ arquivo CSF ausente: ${entry}" rc=1 continue fi if ! grep -qE "^${ip}([[:space:]#]|$)" "$entry"; then echo "${ip} # admin cpanel-bootstrap" >> "$entry" || rc=1 fi done log " ✓ IP de gestão ${ip} adicionado a csf.allow + csf.ignore" done return $rc } # Edita um par chave/valor no php.ini via sed. # Se a chave existe (comentada ou não), substitui; senão, anexa. set_ini_directive() { local file="$1" key="$2" value="$3" # Escapa `.` do key (vira "qualquer char" no regex se não escapado). # Ex: `date.timezone` sem escape também casaria `dateXtimezone`. local key_re="${key//./\\.}" # Escapa caracteres especiais para o sed (/ e & no valor) local value_escaped="${value//\//\\/}" value_escaped="${value_escaped//&/\\&}" if grep -qE "^[[:space:]]*;?[[:space:]]*${key_re}[[:space:]]*=" "$file"; then sed -i "s|^[[:space:]]*;\?[[:space:]]*${key_re}[[:space:]]*=.*|${key} = ${value_escaped}|" "$file" else echo "${key} = ${value}" >> "$file" fi } # IMPORTANTE: whmapi1 set_tweaksetting retorna exit code 0 MESMO quando # falha. O verdadeiro resultado está no output (result: 1 = sucesso). # Mesmo com result: 1, o valor pode ser sobrescrito por processos de # background do cPanel logo após o install. Estratégia belt-and-suspenders: # SEMPRE faz whmapi1 (triggers side-effects) E sed no cpanel.config # (garante persistência). apply_tweak() { local key="$1" value="$2" get_out # Grava a intenção de forma persistente. O regex cobre # também chaves presentes SEM valor (linha "chave" ou "chave=" — o # cpanel.config usa isso para "default automático", ex.: securetmp_file_size). if [[ -f "$CPANEL_CONFIG" ]]; then if grep -qE "^${key}(=|$)" "$CPANEL_CONFIG"; then sed -i -E "s|^${key}(=.*)?$|${key}=${value}|" "$CPANEL_CONFIG" || return 1 else echo "${key}=${value}" >> "$CPANEL_CONFIG" || return 1 fi else warn " ✗ falha em ${key} (cpanel.config não encontrado)" return 1 fi # Com licença, a API é obrigatória para aplicar side-effects e confirmar o # valor. Sem licença, o --finish repete este bloco automaticamente. if [[ "${WHMAPI_OK:-0}" == "1" ]]; then whmapi_json_success set_tweaksetting key="$key" value="$value" >/dev/null || \ return 1 get_out="$(whmapi_json_success get_tweaksetting key="$key")" || return 1 jq -e --arg wanted "$value" \ '[.. | objects | .value? | tostring] | index($wanted) != null' \ >/dev/null 2>&1 <<<"$get_out" || return 1 log " ✓ ${key} = ${value} (arquivo + API confirmados)" else log " ✓ ${key} = ${value} persistido (API pendente de licença)" fi } # Variante defensiva para chaves que podem não existir em todas as versões # do cPanel. Ordem: (1) a chave já aparece no cpanel.config → é válida, # aplica; (2) o get_tweaksetting confirma → aplica; (3) distingue API fora # do ar (ex.: licença inválida) de chave realmente inexistente — os dois # warns anteriores eram um só e induziram diagnóstico errado em teste. apply_tweak_if_exists() { local key="$1" value="$2" get_out if grep -qE "^${key}(=|$)" "$CPANEL_CONFIG" 2>/dev/null; then apply_tweak "$key" "$value" || return 1 return 0 fi if [[ "${WHMAPI_OK:-0}" == "1" ]] && \ get_out="$(whmapi_json_success get_tweaksetting key="$key")"; then apply_tweak "$key" "$value" || return 1 elif [[ "${WHMAPI_OK:-0}" != "1" ]]; then warn " ✗ API pendente de licença — '${key}' será reconciliado pelo --finish" else warn " ✗ API não confirmou o tweak '${key}' (chave ausente, timeout ou erro transitório)" return 1 fi } apply_core_tweaks() { apply_tweak alwaysredirecttossl 1 || return 1 apply_tweak requiressl 1 || return 1 apply_tweak skipboxtrapper 1 || return 1 apply_tweak smtpmailgidonly 0 || return 1 apply_tweak popbeforesmtp 0 || return 1 apply_tweak allowremotedomains 0 || return 1 apply_tweak eximmailtrap 1 || return 1 apply_tweak emailusersbandwidthexceed 1 || return 1 apply_tweak referrersafety 1 || return 1 apply_tweak server_locale "$SERVER_LOCALE" || return 1 } restrict_compilers() { local compiler_script="/usr/local/cpanel/scripts/compilers" local gcc_uid gcc_group gcc_mode [[ -x "$compiler_script" ]] || { warn " ✗ script oficial de controle de compiladores não encontrado: ${compiler_script}" return 1 } "$compiler_script" off >>"${LOG_DIR}/hardening.log" 2>&1 || { warn " ✗ /scripts/compilers off falhou" return 1 } # A documentação do WHM define o estado restrito do gcc como # root:compiler 0750. Se o compilador não está instalado, o script oficial # ter concluído já representa a política mais restritiva possível. if [[ -e /usr/bin/gcc ]]; then gcc_uid="$(stat -Lc '%u' /usr/bin/gcc 2>/dev/null || echo '?')" gcc_group="$(stat -Lc '%G' /usr/bin/gcc 2>/dev/null || echo '?')" gcc_mode="$(stat -Lc '%a' /usr/bin/gcc 2>/dev/null || echo '?')" [[ "$gcc_uid" == "0" && "$gcc_group" == "compiler" && "$gcc_mode" == "750" ]] || { warn " ✗ gcc não ficou root:compiler 0750 (uid=${gcc_uid}, grupo=${gcc_group}, modo=${gcc_mode})" return 1 } fi touch "${STATE_DIR}/compilers.restricted" || return 1 log " ✓ Compiladores restritos a root/grupo compiler (/scripts/compilers off)" } apply_security_tweaks() { [[ "$SECURITY_TWEAKS" == "1" ]] || return 0 apply_tweak_if_exists create_account_dkim 1 || return 1 apply_tweak_if_exists create_account_spf 1 || return 1 apply_tweak_if_exists jaildefaultshell 1 || return 1 configure_min_password_strength || return 1 restrict_compilers || return 1 } configure_min_password_strength() { local strength_out if [[ "${WHMAPI_OK:-0}" != "1" ]]; then log " ✓ força mínima de senha ${MIN_PW_STRENGTH} pendente da licença/API" return 0 fi whmapi_json_success setminimumpasswordstrengths \ default="$MIN_PW_STRENGTH" >/dev/null || return 1 strength_out="$(whmapi_json_success getminimumpasswordstrengths name=default)" || return 1 jq -e --arg wanted "$MIN_PW_STRENGTH" \ '(.data.default | tostring) == $wanted' \ >/dev/null 2>&1 <<<"$strength_out" || return 1 log " ✓ força mínima de senha = ${MIN_PW_STRENGTH} (API dedicada confirmada)" } # ----- tuning MySQL/MariaDB por RAM ------------------------------------------ # O cPanel instala MariaDB com defaults de fábrica (buffer pool de 128M) e # NÃO faz tuning automático — este é o maior gargalo típico de shared hosting. # Dimensiona deixando ~65-75% da RAM para PHP-FPM/Apache/Exim/Dovecot. write_mariadb_systemd_limits() { local dropin_dir="/etc/systemd/system/mariadb.service.d" local dropin_file="${dropin_dir}/zz-cpanel-bootstrap-limits.conf" tmp # Criado na fase 1, antes de o cPanel instalar a unit. O daemon-reload já # executado no fim da fase 1 carrega o drop-in quando mariadb.service passa # a existir, sem recarregar systemd de dentro da própria fase 2. install -d -m 0755 "$dropin_dir" || return 1 tmp="$(mktemp "${dropin_dir}/.zz-cpanel-bootstrap-limits.XXXXXX")" || return 1 if ! cat > "$tmp" <<'LIMITS' [Service] LimitNOFILE=65535 LIMITS then rm -f "$tmp" return 1 fi chmod 0644 "$tmp" || { rm -f "$tmp"; return 1; } mv -f "$tmp" "$dropin_file" || { rm -f "$tmp"; return 1; } log " ✓ Limite systemd do MariaDB preparado: LimitNOFILE=65535" } write_mysql_tuning() { local mem_mb bp bp_bytes lf mc mysql_tuning_tmp mem_mb="$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo)" if ! is_integer "$mem_mb"; then warn "Não foi possível medir RAM — tuning do MySQL não aplicado" return 1 fi if (( mem_mb < 4096 )); then bp="512M"; bp_bytes=$((512 * 1024 * 1024)); lf="128M"; mc="150" elif (( mem_mb < 8192 )); then bp="1G"; bp_bytes=$((1024 * 1024 * 1024)); lf="256M"; mc="200" elif (( mem_mb < 16384 )); then bp="3G"; bp_bytes=$((3 * 1024 * 1024 * 1024)); lf="512M"; mc="300" elif (( mem_mb < 32768 )); then bp="6G"; bp_bytes=$((6 * 1024 * 1024 * 1024)); lf="1G"; mc="400" else bp="$(( mem_mb / 4096 ))G" bp_bytes=$(( (mem_mb / 4096) * 1024 * 1024 * 1024 )) lf="2G"; mc="500" fi log "Aplicando tuning do MySQL/MariaDB (RAM ${mem_mb} MB → buffer pool ${bp}, max_connections ${mc})" install -d -m 0755 /etc/my.cnf.d || return 1 mysql_tuning_tmp="$(mktemp /etc/my.cnf.d/.zz-cpanel-bootstrap.XXXXXX)" || return 1 if ! cat > "$mysql_tuning_tmp" <> /etc/my.cnf || return 1 fi # O cPanel grava innodb_buffer_pool_size (128M) e afins DIRETO na seção # [mysqld] do /etc/my.cnf — que é lida DEPOIS do !includedir (topo do # arquivo), então o valor dele venceria o nosso drop-in. Atualizamos as # chaves conflitantes em ambos os lugares. if [[ -f /etc/my.cnf ]]; then cp -a /etc/my.cnf "/etc/my.cnf.bak.$(date +%Y%m%d%H%M%S)" || return 1 local mypair mykey myval for mypair in "innodb_buffer_pool_size:${bp}" "innodb_log_file_size:${lf}" \ "max_connections:${mc}" "max_allowed_packet:256M" "table_open_cache:4000"; do mykey="${mypair%%:*}" myval="${mypair#*:}" if grep -qE "^${mykey}[[:space:]]*=" /etc/my.cnf; then sed -i -E "s|^${mykey}[[:space:]]*=.*|${mykey}=${myval}|" /etc/my.cnf || return 1 log " ✓ /etc/my.cnf: ${mykey} = ${myval} (valor do cPanel sobrescrito)" fi done fi if command -v my_print_defaults >/dev/null 2>&1 && \ ! my_print_defaults mysqld >>"${LOG_DIR}/mysql-tuning.log" 2>&1; then warn " ✗ configuração MySQL inválida — veja ${LOG_DIR}/mysql-tuning.log" return 1 fi /usr/local/cpanel/scripts/restartsrv_mysql \ >>"${LOG_DIR}/mysql-tuning.log" 2>&1 || { warn " ✗ falha ao reiniciar MySQL — veja ${LOG_DIR}/mysql-tuning.log" return 1 } # Confirma que o valor desejado está EM VIGOR (não só no arquivo). local bp_check bp_check="$(timeout 30 mysql -N -B -e 'SELECT @@innodb_buffer_pool_size' 2>/dev/null || true)" if is_integer "$bp_check" && (( bp_check == bp_bytes )); then log " ✓ Tuning em vigor: innodb_buffer_pool_size = $(( bp_check / 1024 / 1024 )) MB" return 0 fi warn " ✗ buffer pool em vigor (${bp_check:-n/d}) difere do esperado (${bp_bytes}) — veja /etc/my.cnf e ${LOG_DIR}/mysql-tuning.log" return 1 } # ----- WHM basic setup (wwwacct.conf) ---------------------------------------- wwwacct_set() { local key="$1" value="$2" f="/etc/wwwacct.conf" [[ -f "$f" ]] || touch "$f" if grep -qE "^${key}( |$)" "$f"; then sed -i -E "s|^${key}( .*)?$|${key} ${value}|" "$f" else echo "${key} ${value}" >> "$f" fi log " ✓ wwwacct.conf: ${key} = ${value}" } detect_primary_ipv4_interface() { local dev="" # Preferimos a interface que possui o IP público. Em NAT 1:1 esse IP não # aparece localmente; nesse caso usamos a interface da rota IPv4 default. if is_ipv4 "${SERVER_PUBLIC_IP:-}"; then dev="$(ip -4 -o addr show scope global 2>/dev/null | awk -v wanted="$SERVER_PUBLIC_IP" ' { split($4, addr, "/"); if (addr[1] == wanted) { print $2; exit } } ')" fi if [[ -z "$dev" ]]; then dev="$(ip -4 route get 1.1.1.1 2>/dev/null | awk ' { for (i=1; i<=NF; i++) if ($i == "dev") { print $(i+1); exit } } ')" fi [[ "$dev" =~ ^[A-Za-z0-9_.:-]+$ && "$dev" != "lo" && -d "/sys/class/net/${dev}" ]] || return 1 printf '%s\n' "$dev" } ensure_wwwacct_ethdev() { local expected current expected="$(detect_primary_ipv4_interface)" || { warn "não foi possível detectar a interface IPv4 primária para o cPanel" return 1 } current="$(awk '$1 == "ETHDEV" { print $2; exit }' /etc/wwwacct.conf 2>/dev/null || true)" if [[ "$current" != "$expected" ]]; then wwwacct_set ETHDEV "$expected" || return 1 fi current="$(awk '$1 == "ETHDEV" { print $2; exit }' /etc/wwwacct.conf 2>/dev/null || true)" [[ "$current" == "$expected" ]] || return 1 log " ✓ Interface principal do cPanel confirmada: ETHDEV = ${expected}" } # ----- logrotate dos logs do bootstrap --------------------------------------- write_bootstrap_logrotate() { log "Configurando logrotate para ${LOG_DIR}" cat > /etc/logrotate.d/cpanel-bootstrap </dev/null 2>&1 || { warn "não foi possível instalar tuned/sysstat — pulando" return 0 } # Perfil custom herdando throughput-performance, repetindo os valores de # memória do nosso sysctl para o perfil não sobrepô-los em runtime. install -d -m 0755 /etc/tuned/cpanel-hosting cat > /etc/tuned/cpanel-hosting/tuned.conf <<'TUNED' # Gerado por cpanel-bootstrap.sh [main] summary=Perfil para servidor de hospedagem cPanel (base throughput-performance) include=throughput-performance [sysctl] vm.swappiness=10 vm.vfs_cache_pressure=50 TUNED if systemctl enable --now tuned >/dev/null 2>&1 && tuned-adm profile cpanel-hosting 2>/dev/null; then log " ✓ Perfil tuned 'cpanel-hosting' ativo" else warn " ✗ não foi possível ativar o perfil tuned — seguindo" fi systemctl enable --now sysstat >/dev/null 2>&1 && \ log " ✓ sysstat ativo (histórico com 'sar')" || \ warn " ✗ não foi possível ativar o sysstat" } # ----- MOTD ------------------------------------------------------------------- write_motd() { local public_ip="${SERVER_PUBLIC_IP:-n/d}" current_hostname whm_hostname current_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || true)" [[ -n "$current_hostname" ]] || current_hostname="$HOSTNAME_FQDN" if is_valid_fqdn "$current_hostname"; then whm_hostname="$current_hostname" else whm_hostname="$public_ip" fi # Remove blocos antigos do bootstrap (inclusive avisos de falha) e regrava sed -i '/cpanel-bootstrap/d' /etc/motd 2>/dev/null || true cat >> /etc/motd <>"${STATE_DIR}/phase2.lock" if ! flock -n 201; then die "a fase 2 está em execução NESTE MOMENTO. Acompanhe: tail -f ${LOG_DIR}/phase2.log" fi exec 201>&- fi die "fase 1 já concluída (${STATE_DIR}/phase1.done). A fase 2 roda via systemd após o boot — status: ${INSTALL_PATH} --status. Para forçar nova fase 1, remova os marcadores em ${STATE_DIR}." fi # Valida o caminho do script ANTES de qualquer mutação no sistema — # com `curl | bash` o $0 não é um arquivo e a auto-instalação para a # fase 2 seria impossível (falharia só no meio, com o servidor # meio-configurado). local script_source script_source="$(realpath -e "$0" 2>/dev/null || true)" if [[ -z "$script_source" || ! -f "$script_source" ]]; then die "não foi possível localizar o arquivo deste script (\$0='$0'). Salve-o em disco e execute: bash cpanel-bootstrap.sh (execução via curl | bash não é suportada)" fi install -d -m 0700 "$LOG_DIR" "$STATE_DIR" "$STEP_DIR" exec 199>"${STATE_DIR}/phase1.lock" flock -n 199 || die "outra instância da fase 1 já está em execução" local script_uid script_mode protected_script script_uid="$(stat -c '%u' "$script_source" 2>/dev/null || echo '?')" script_mode="$(stat -c '%a' "$script_source" 2>/dev/null || echo '?')" [[ "$script_uid" == "0" ]] || die "o arquivo do bootstrap precisa pertencer ao root" # Recusa bits de escrita para grupo/outros. Classes como [0-4] seriam # incorretas porque aceitariam 2/3, exatamente os bits de escrita. [[ "$script_mode" =~ ^[0-7]{3}$ ]] && \ (( (8#$script_mode & 8#022) == 0 )) || \ die "arquivo do bootstrap não pode ser gravável por grupo/outros nem ter bits especiais (modo atual: $script_mode)" protected_script="${STATE_DIR}/bootstrap-source.sh" install -m 0700 "$script_source" "$protected_script" script_source="$protected_script" load_runtime_config_phase1 collect_interactive_config # Pergunta somente na instalação real. O --preflight-only não persiste # segredos e, portanto, não deve solicitar o webhook para depois repeti-lo. prompt_notify_webhook exec > >(tee -a "${LOG_DIR}/phase1.log") 2>&1 log "===================================================================" log " FASE 1 iniciada em $HOSTNAME_FQDN (script v${VERSION})" log "===================================================================" validate_config persist_runtime_config if [[ -n "$ADMIN_SRC_IP" ]]; then log "IP de gestão detectado pela sessão SSH: ${ADMIN_SRC_IP} (whitelist no CSF/cPHulk na fase 2)" fi if ! command -v curl >/dev/null 2>&1; then log "curl não encontrado — instalando via repositório base do AlmaLinux" yum install -y curl fi run_preflight persist_runtime_config # --- 1. yum update ------------------------------------------------------ log "Atualizando pacotes do sistema (yum update -y)" yum update -y # --- 2. Ferramentas base sem repositórios externos ---------------------- log "Instalando ferramentas base: wget, tar, bind-utils, lsof, curl, tzdata, jq, dnf-plugins-core" yum install -y wget tar bind-utils lsof curl tzdata jq dnf-plugins-core grubby # Atualizações podem elevar o minor release (ex.: 9.4 → 9.8). Confirma o # sistema resultante antes de gravar configurações e agendar o instalador. validate_platform_basics local alma_minor alma_version_id alma_version_id="$(source /etc/os-release; printf '%s' "$VERSION_ID")" alma_minor="${alma_version_id#9.}" alma_minor="${alma_minor%%.*}" is_integer "$alma_minor" && (( alma_minor >= 5 )) || \ die "AlmaLinux 9.5+ é exigido pelos tiers atuais do cPanel (detectado VERSION_ID=$alma_version_id)" # screen não está disponível em todos os templates/repos AlmaLinux 9. # É útil, mas não é pré-requisito para instalar o cPanel. log "Tentando instalar screen (opcional)" yum install -y screen || warn "Pacote screen não disponível nos repos base — seguindo sem ele" # --- 3. Relógio: NTP contínuo + timezone -------------------------------- ensure_time_sync log "Definindo timezone do sistema para $TIMEZONE" ensure_timezone_available timedatectl set-timezone "$TIMEZONE" log "Sincronizando relógio de hardware (hwclock -w)" # Não fatal: VPS/containers sem /dev/rtc (Virtuozzo/LXC/Xen PV) falham # aqui, e o relógio já é mantido pelo chrony/kvm-clock nesses ambientes. hwclock -w 2>/dev/null || \ warn "hwclock indisponível (sem RTC neste ambiente virtualizado) — chronyd mantém o relógio" # --- 4. Hostname FQDN --------------------------------------------------- log "Definindo hostname para $HOSTNAME_FQDN" configure_cloud_init_hostname_persistence || \ die "não foi possível impedir o cloud-init de sobrescrever o hostname" enforce_expected_hostname || \ die "não foi possível definir ou validar o hostname $HOSTNAME_FQDN" # --- 4b. SSH: chave do admin + hardening -------------------------------- install_admin_ssh_key harden_sshd # --- 4c. Swap (cloud images costumam vir sem) --------------------------- ensure_swap # --- 5. SELinux disabled ------------------------------------------------ log "Desativando SELinux permanente (config + argumentos de todos os kernels)" [[ -f /etc/selinux/config ]] && sed -i.bak -E 's/^SELINUX=.*/SELINUX=disabled/' /etc/selinux/config if ! systemd-detect-virt --container >/dev/null 2>&1; then grubby --update-kernel=ALL --remove-args="selinux=1 enforcing=1" || \ die "falha ao remover argumentos SELinux antigos dos kernels" grubby --update-kernel=ALL --args="selinux=0" || \ die "falha ao adicionar selinux=0 aos kernels" local grub_args_count grub_selinux_count grub_args_count="$(grubby --info=ALL | grep -c '^args=' || true)" grub_selinux_count="$(grubby --info=ALL | grep '^args=' | grep -c 'selinux=0' || true)" (( grub_args_count > 0 && grub_args_count == grub_selinux_count )) || \ die "nem todas as entradas de kernel receberam selinux=0" else warn "Ambiente container detectado: não há bootloader para atualizar; SELinux precisa estar desativado no host" fi setenforce 0 2>/dev/null || true # --- 6. firewall de transição + remoção do firewalld -------------------- # Carrega primeiro nossa tabela dedicada; assim nunca existe janela sem # firewall entre parar/remover o firewalld e instalar o CSF. setup_gap_firewall log "Parando, desabilitando e mascarando firewalld" systemctl stop firewalld.service 2>/dev/null || true systemctl disable firewalld.service 2>/dev/null || true systemctl mask firewalld.service 2>/dev/null || true if rpm -q firewalld >/dev/null 2>&1; then yum remove -y firewalld fi rpm -q firewalld >/dev/null 2>&1 && die "firewalld continua instalado após tentativa de remoção" # --- 7. sysctl tuning --------------------------------------------------- log "Aplicando ajustes de sysctl para servidor de hospedagem" cat > /etc/sysctl.d/99-cpanel-bootstrap.conf <<'SYSCTL' # Ajustes recomendados para servidor cPanel de hospedagem vm.swappiness = 10 vm.vfs_cache_pressure = 50 kernel.panic = 10 kernel.panic_on_oops = 1 net.ipv4.tcp_fin_timeout = 15 net.ipv4.tcp_keepalive_time = 300 net.ipv4.tcp_max_syn_backlog = 4096 net.core.somaxconn = 4096 net.ipv4.tcp_syncookies = 1 # Loose mode preserva anti-spoof sem quebrar NAT 1:1, múltiplas interfaces # ou roteamento assimétrico comum em clouds. net.ipv4.conf.all.rp_filter = 2 net.ipv4.conf.default.rp_filter = 2 fs.file-max = 2097152 SYSCTL sysctl --system >/dev/null # O cPanel instalará MariaDB somente na fase 2. Preparamos o drop-in agora # para que o primeiro start já tenha file descriptors suficientes para # table_open_cache=4000, sem warning/redução silenciosa no boot. write_mariadb_systemd_limits || \ die "não foi possível preparar o limite systemd do MariaDB" # --- 7b. tuned + sysstat ------------------------------------------------- setup_tuned_sysstat # Mantém os rate limits seguros do rsyslog/imjournal. Desabilitá-los # globalmente permite que um processo local cause exaustão de disco. log "Mantendo configuração nativa e rate limits do rsyslog/imjournal" # --- 8c. logrotate dos logs do bootstrap -------------------------------- write_bootstrap_logrotate # Perfil EasyApache opcional precisa existir antes de chamar `latest`. prepare_ea4_profile # --- 9. Auto-instalação do script, serviço systemd e timer ----------- log "Instalando uma cópia deste script em $INSTALL_PATH" # O script pode ter sido customizado localmente; modo 0700 impede que # tokens hardcoded por engano fiquem legíveis por usuários hospedados. install -m 0700 "$script_source" "$INSTALL_PATH" log "Criando serviço systemd + timer que disparam a fase 2 após o reboot" # Serviço: SEM WantedBy (não é parte da ativação do multi-user.target). # É disparado exclusivamente pelo timer. O ExecStopPost registra falhas # no motd/console e dispara notificação (--on-failure). cat > "$SERVICE_PATH" < "$TIMER_PATH" <instance; my %want = ( timeout => 60, keepalive => "On", keepalivetimeout => 5, maxkeepaliverequests => 100, serverlimit => 32, startservers => 4, servertokens => "ProductOnly", serversignature => "Off", traceenable => "Off", fileetag => "None", ); my @applied; for my $k (sort keys %want) { $c->can($k) or die "accessor obrigatório ausente: $k\n"; $c->$k($want{$k}); push @applied, [ $k, "$want{$k}" ]; } my %aliased = ( maxrequestworkers => [ 800, "maxclients" ], maxconnectionsperchild => [ 10000, "maxrequestsperchild" ], ); for my $k (sort keys %aliased) { my ($v, $old) = @{ $aliased{$k} }; my ($m) = grep { $c->can($_) } ($k, $old); $m or die "accessor obrigatório ausente: $k/$old\n"; $c->$m($v); push @applied, [ $m, "$v" ]; } $c->save; my $saved = Cpanel::EA4::Conf->instance; for my $item (@applied) { my ($m, $expected) = @$item; $saved->can($m) or die "accessor sumiu após save: $m\n"; my $actual = $saved->$m(); defined($actual) && lc("$actual") eq lc($expected) or die "valor não persistiu: $m (esperado $expected, obtido " . (defined($actual) ? $actual : "undef") . ")\n"; } ' 2>>"${LOG_DIR}/apache-mpm.log"; then log " ✓ Global Configuration gravada via Cpanel::EA4::Conf (visível no WHM → Apache → Global Configuration)" else warn " ✗ falha ao gravar via Cpanel::EA4::Conf — veja ${LOG_DIR}/apache-mpm.log (tuning ficará só no include e pode ser sobrescrito pelo WHM)" return 1 fi } # ----- Pure-FTPd: datastore persistente --------------------------------------- # /etc/pure-ftpd.conf é REGENERADO pelo cPanel a partir do datastore em # /var/cpanel/conf/pureftpd/ — edições diretas no .conf são descartadas em # qualquer rebuild. Gravamos no override 'local', que sobrevive. pureftpd_datastore_set() { local key="$1" value="$2" f="/var/cpanel/conf/pureftpd/local" install -d -m 0755 /var/cpanel/conf/pureftpd touch "$f" if grep -qE "^${key}:" "$f"; then sed -i -E "s|^${key}:.*|${key}: ${value}|" "$f" else printf '%s: %s\n' "$key" "$value" >> "$f" fi log " ✓ pureftpd datastore (local): ${key} = ${value}" } # Regenera /etc/pure-ftpd.conf a partir do datastore. O mecanismo suportado # e documentado é o setupftpserver --force (não existe build_pureftpd_config # nas versões atuais). rebuild_pureftpd_config() { /usr/local/cpanel/scripts/setupftpserver pure-ftpd --force \ >>"${LOG_DIR}/pureftpd-install.log" 2>&1 } panel_ini_set_global() { local key="$1" value="$2" file="/var/cpanel/panel.ini" tmp install -d -m 0755 /var/cpanel || return 1 if [[ -e "$file" && ( ! -f "$file" || -L "$file" ) ]]; then warn " ✗ ${file} não é arquivo regular seguro" return 1 fi tmp="$(mktemp /var/cpanel/.panel.ini.cpanel-bootstrap.XXXXXX)" || return 1 if [[ -f "$file" ]]; then if ! awk -v key="$key" -v value="$value" ' function lhs(line, out) { out=line sub(/^[[:space:]]*/, "", out) sub(/[[:space:]]*=.*/, "", out) return out } /^[[:space:]]*\[global\][[:space:]]*$/ { if (in_global && !key_written) { print key " = " value key_written=1 } in_global=1 global_seen=1 print next } /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { if (in_global && !key_written) { print key " = " value key_written=1 } in_global=0 print next } { if (in_global && lhs($0) == key) { if (!key_written) print key " = " value key_written=1 next } print } END { if (in_global && !key_written) print key " = " value if (!global_seen) { if (NR > 0) print "" print "[global]" print key " = " value } } ' "$file" > "$tmp"; then rm -f "$tmp" return 1 fi cp -a "$file" "${file}.bak.$(date +%Y%m%d%H%M%S)" || { rm -f "$tmp" return 1 } else printf '[global]\n%s = %s\n' "$key" "$value" > "$tmp" || { rm -f "$tmp" return 1 } fi chmod 0644 "$tmp" || { rm -f "$tmp"; return 1; } mv -f "$tmp" "$file" || { rm -f "$tmp"; return 1; } awk -v key="$key" -v wanted="$value" ' function trim(s) { sub(/^[[:space:]]*/, "", s); sub(/[[:space:]]*$/, "", s); return s } /^[[:space:]]*\[global\][[:space:]]*$/ { in_global=1; next } /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { in_global=0; next } in_global { line=$0 split(line, pair, "=") if (trim(pair[1]) == key) { sub(/^[^=]*=/, "", line) if (trim(line) == wanted) found=1 } } END { exit !found } ' "$file" } # ----- AutoSSL ---------------------------------------------------------------- write_hostname_certificate_status() { local message="$1" tmp_status tmp_status="$(mktemp "${STATE_DIR}/.hostname-cert.status.XXXXXX")" || return 1 printf '%s\n' "$message" > "$tmp_status" || { rm -f "$tmp_status" return 1 } chmod 0600 "$tmp_status" || { rm -f "$tmp_status"; return 1; } mv -f "$tmp_status" "$HOSTNAME_CERT_STATUS_PATH" } # Confere o certificado que o cliente realmente recebe em WHM. O exit 0 do # checkallsslcerts apenas informa que o processo terminou; ele também retorna # 0 quando DNS/DCV não permitem emitir um novo certificado. hostname_service_certificate_valid() { local expected_hostname="${1:-$HOSTNAME_FQDN}" local tls_output leaf_pem command -v openssl >/dev/null 2>&1 || return 1 tls_output="$( timeout 20 openssl s_client -showcerts \ -connect 127.0.0.1:2087 \ -servername "$expected_hostname" \ -verify_hostname "$expected_hostname" \ -verify_return_error /dev/null )" || return 1 leaf_pem="$(awk ' /-----BEGIN CERTIFICATE-----/ { capture=1 } capture { print } /-----END CERTIFICATE-----/ { exit } ' <<<"$tls_output")" [[ "$leaf_pem" == *'-----BEGIN CERTIFICATE-----'* ]] || return 1 openssl x509 -noout -checkhost "$expected_hostname" \ >/dev/null 2>&1 <<<"$leaf_pem" || return 1 # Não publica sucesso para um certificado que vencerá no próximo ciclo. openssl x509 -noout -checkend 86400 \ >/dev/null 2>&1 <<<"$leaf_pem" } mark_hostname_certificate_pending() { local reason="$1" tmp_marker tmp_marker="$(mktemp "${STATE_DIR}/.hostname-cert.pending.XXXXXX")" || return 1 { printf 'hostname=%s\n' "$HOSTNAME_FQDN" printf 'pending_since=%s\n' "$(date --iso-8601=seconds)" printf 'reason=%s\n' "$reason" } > "$tmp_marker" || { rm -f "$tmp_marker"; return 1; } chmod 0600 "$tmp_marker" || { rm -f "$tmp_marker"; return 1; } mv -f "$tmp_marker" "$HOSTNAME_CERT_PENDING_MARKER" } hostname_certificate_request_allowed() { local recorded_hostname recorded_epoch now [[ -e "$HOSTNAME_CERT_ATTEMPT_PATH" ]] || return 0 [[ -f "$HOSTNAME_CERT_ATTEMPT_PATH" && ! -L "$HOSTNAME_CERT_ATTEMPT_PATH" ]] || return 1 recorded_hostname="$(awk -F= '$1 == "hostname" { print $2; exit }' "$HOSTNAME_CERT_ATTEMPT_PATH")" recorded_epoch="$(awk -F= '$1 == "attempt_epoch" { print $2; exit }' "$HOSTNAME_CERT_ATTEMPT_PATH")" [[ "$recorded_hostname" == "$HOSTNAME_FQDN" ]] || return 0 is_integer "$recorded_epoch" || return 0 now="$(date +%s)" (( now - recorded_epoch >= HOSTNAME_CERT_RETRY_COOLDOWN )) } record_hostname_certificate_attempt() { local tmp_attempt tmp_attempt="$(mktemp "${STATE_DIR}/.hostname-cert.attempt.XXXXXX")" || return 1 { printf 'hostname=%s\n' "$HOSTNAME_FQDN" printf 'attempt_epoch=%s\n' "$(date +%s)" printf 'attempted_at=%s\n' "$(date --iso-8601=seconds)" } > "$tmp_attempt" || { rm -f "$tmp_attempt"; return 1; } chmod 0600 "$tmp_attempt" || { rm -f "$tmp_attempt"; return 1; } mv -f "$tmp_attempt" "$HOSTNAME_CERT_ATTEMPT_PATH" } ensure_hostname_service_certificate() { local dns_gate_rc=0 checkall_rc=0 verify_attempt local cert_log="${LOG_DIR}/hostname-cert.log" if hostname_service_certificate_valid; then rm -f "$HOSTNAME_CERT_PENDING_MARKER" write_hostname_certificate_status \ "válido, confiável e servido em 2087 para ${HOSTNAME_FQDN}" || return 1 return 0 fi # Usa exatamente o resolver local antes da chamada. Ainda assim, a # resolução recursiva interna do cPanel pode sofrer SERVFAIL transitório; # por isso o resultado final sempre é o certificado efetivamente servido. validate_cpanel_hostname_dns_gate 3 5 || dns_gate_rc=$? if (( dns_gate_rc != 0 )); then mark_hostname_certificate_pending \ "resolver local: ${CPANEL_HOSTNAME_DNS_STATUS}" || return 1 write_hostname_certificate_status \ "pendente: resolver local não confirmou ${HOSTNAME_FQDN} (${CPANEL_HOSTNAME_DNS_STATUS})" || return 1 return 2 fi if ! hostname_certificate_request_allowed; then mark_hostname_certificate_pending \ "aguardando cooldown de ${HOSTNAME_CERT_RETRY_COOLDOWN}s" || return 1 write_hostname_certificate_status \ "pendente: tentativa já solicitada; cPanel/upcp fará o retry (cooldown 24h)" || return 1 return 2 fi [[ -x /usr/local/cpanel/bin/checkallsslcerts ]] || return 1 record_hostname_certificate_attempt || return 1 touch "$cert_log" || return 1 chmod 0600 "$cert_log" || return 1 printf '\n[%s] Solicitando certificado do hostname %s\n' \ "$(date '+%F %T')" "$HOSTNAME_FQDN" >> "$cert_log" timeout --signal=TERM --kill-after=30s 600 \ /usr/local/cpanel/bin/checkallsslcerts --allow-retry \ >>"$cert_log" 2>&1 || checkall_rc=$? # A instalação pode reiniciar o cpsrvd logo depois de gravar o PEM. for verify_attempt in 1 2 3 4 5 6; do if hostname_service_certificate_valid; then rm -f "$HOSTNAME_CERT_PENDING_MARKER" write_hostname_certificate_status \ "válido, confiável e servido em 2087 para ${HOSTNAME_FQDN}" || return 1 log " ✓ Certificado confiável servido em 2087 cobre ${HOSTNAME_FQDN}" return 0 fi (( verify_attempt < 6 )) && sleep 5 done mark_hostname_certificate_pending \ "checkallsslcerts exit ${checkall_rc}; aguardando retry oficial" || return 1 write_hostname_certificate_status \ "pendente: 2087 ainda não serve certificado confiável para ${HOSTNAME_FQDN}; retry oficial/cooldown ativo" || return 1 warn " ⓘ Certificado ainda pendente; o cPanel recebeu --allow-retry e novas solicitações têm cooldown de 24h (log: ${cert_log})" return 2 } configure_autossl() { [[ "${WHMAPI_OK:-0}" == "1" ]] || return 1 local providers_json="" tos_url="" local -a provider_args=() if [[ -n "$AUTOSSL_PROVIDER" ]]; then log "Configurando AutoSSL: provider ${AUTOSSL_PROVIDER}" provider_args=("provider=${AUTOSSL_PROVIDER}") providers_json="$(whmapi_json_success get_autossl_providers)" || { warn " ✗ não foi possível listar os providers AutoSSL" return 1 } if ! jq -e --arg provider "$AUTOSSL_PROVIDER" \ '.data.payload | map(select(.module_name == $provider)) | length == 1' \ >/dev/null 2>&1 <<<"$providers_json"; then warn " ✗ provider AutoSSL '${AUTOSSL_PROVIDER}' não existe ou está duplicado" return 1 fi tos_url="$(jq -r --arg provider "$AUTOSSL_PROVIDER" \ '[.data.payload[] | select(.module_name == $provider) | .x_terms_of_service // empty] | first // empty' \ <<<"$providers_json")" || return 1 if [[ -n "$tos_url" ]]; then provider_args+=("x_terms_of_service_accepted=${tos_url}") elif [[ "$AUTOSSL_PROVIDER" == "LetsEncrypt" ]]; then warn " ✗ provider LetsEncrypt não informou a URL dos termos de serviço" return 1 fi whmapi_json_success set_autossl_provider "${provider_args[@]}" >/dev/null || { warn " ✗ WHM API recusou o provider AutoSSL '${AUTOSSL_PROVIDER}'" return 1 } providers_json="$(whmapi_json_success get_autossl_providers)" || return 1 jq -e --arg provider "$AUTOSSL_PROVIDER" ' .data.payload | any(.module_name == $provider and ((.enabled | tostring) == "1"))' \ >/dev/null 2>&1 <<<"$providers_json" || { warn " ✗ get_autossl_providers não confirmou '${AUTOSSL_PROVIDER}' como ativo" return 1 } log " ✓ AutoSSL provider = ${AUTOSSL_PROVIDER}${tos_url:+ (ToS aceito)}" else log "Mantendo o provider AutoSSL escolhido pelo cPanel" fi local exclude_new_domains="true" if [[ "$AUTOSSL_INCLUDE_NEW_DOMAINS" == "1" ]]; then exclude_new_domains="false" warn "AutoSSL automático para novos domínios ativado: informe os clientes sobre os termos do provider" fi panel_ini_set_global autossl.exclude_new_domains "$exclude_new_domains" || { warn " ✗ não foi possível persistir autossl.exclude_new_domains=${exclude_new_domains}" return 1 } log " ✓ Novos domínios no AutoSSL: $([[ "$AUTOSSL_INCLUDE_NEW_DOMAINS" == "1" ]] && echo incluídos || echo excluídos)" if [[ -z "$AUTOSSL_PROVIDER" ]]; then write_hostname_certificate_status \ "não solicitado; provider AutoSSL atual mantido" || return 1 return 0 fi # Emite o certificado assinado do hostname/serviços (WHM, cPanel, Webmail, # Exim, Dovecot, FTP) agora. Emissão/DCV pode ser assíncrona: o provider e # o retry oficial ficam configurados, mas o status só diz OK após validar # cadeia, hostname e validade do TLS realmente servido em 2087. log " → Emitindo e validando certificado do hostname (pode levar alguns minutos)" local hostname_cert_rc=0 ensure_hostname_service_certificate || hostname_cert_rc=$? case "$hostname_cert_rc" in 0) return 0 ;; 2) warn " Certificado do hostname ficou pendente; a fase 3 continuará verificando sem repetir a solicitação durante o cooldown" return 0 ;; *) return 1 ;; esac } # ----- Backup automático ------------------------------------------------------- configure_backups() { [[ "$BACKUP_ENABLE" == "1" ]] || return 0 [[ "${WHMAPI_OK:-0}" == "1" ]] || return 1 log "Configurando backup diário/semanal do cPanel (WHM → Backup Configuration)" local config_json whmapi_json_success backup_config_set \ backupenable=1 \ backuptype=compressed \ backupdays=0,1,2,3,4,5,6 \ backup_daily_enable=1 \ backup_daily_retention="$BACKUP_DAILY_RETENTION" \ backup_weekly_enable="$BACKUP_WEEKLY_ENABLE" \ backup_weekly_day=0 \ backup_weekly_retention="$BACKUP_WEEKLY_RETENTION" \ backupdir=/backup \ backupaccts=1 \ backupbwdata=1 \ backupfiles=1 \ mysqlbackup=accounts \ check_min_free_space=1 \ disable_metadata=0 \ keeplocal=1 \ >>"${LOG_DIR}/backup-config.log" || { warn " ✗ WHM API recusou a configuração de backup" return 1 } config_json="$(whmapi_json_success backup_config_get)" || return 1 if ! jq -e \ --arg daily "$BACKUP_DAILY_RETENTION" \ --arg weekly_enabled "$BACKUP_WEEKLY_ENABLE" \ --arg weekly "$BACKUP_WEEKLY_RETENTION" ' .data.backup_config | with_entries(.key |= ascii_downcase) | (.backupenable | tostring) == "1" and (.backuptype | tostring) == "compressed" and (.backupdays | tostring) == "0,1,2,3,4,5,6" and (.backup_daily_enable | tostring) == "1" and (.backup_daily_retention | tostring) == $daily and (.backup_weekly_enable | tostring) == $weekly_enabled and (.backup_weekly_day | tostring) == "0" and (.backup_weekly_retention | tostring) == $weekly and (.backupdir | tostring) == "/backup" and (.backupaccts | tostring) == "1" and (.backupbwdata | tostring) == "1" and (.backupfiles | tostring) == "1" and (.mysqlbackup | tostring) == "accounts" and (.check_min_free_space | tostring) == "1" and (.disable_metadata | tostring) == "0" and (.keeplocal | tostring) == "1"' \ >/dev/null 2>&1 <<<"$config_json"; then warn " ✗ backup_config_get não confirmou todos os valores solicitados" return 1 fi log " ✓ Backup local confirmado: diário (${BACKUP_DAILY_RETENTION}) + semanal (${BACKUP_WEEKLY_RETENTION}) em /backup" if [[ -n "$BACKUP_REMOTE_HOST" ]]; then log " → Reconciliando destino SFTP ${BACKUP_REMOTE_USER}@${BACKUP_REMOTE_HOST}:${BACKUP_REMOTE_PATH}" local dest_name legacy_dest_name dest_list dest_count dest_id dest_json dest_name="rapidcloud-sftp-${BACKUP_REMOTE_HOST//./-}" legacy_dest_name="sftp-${BACKUP_REMOTE_HOST//./-}" dest_list="$(whmapi_json_success backup_destination_list)" || return 1 dest_count="$(jq -r --arg name "$dest_name" --arg legacy "$legacy_dest_name" \ '[.data.destination_list[]? | select((.name == $name or .name == $legacy) and .type == "SFTP")] | length' \ <<<"$dest_list")" is_integer "$dest_count" || return 1 (( dest_count <= 1 )) || { warn " ✗ existem ${dest_count} destinos SFTP com o nome '${dest_name}'; corrija a duplicidade no WHM" return 1 } local -a destination_args=( "name=${dest_name}" type=SFTP disabled=0 "host=${BACKUP_REMOTE_HOST}" "port=${BACKUP_REMOTE_PORT}" "username=${BACKUP_REMOTE_USER}" authtype=key "privatekey=${BACKUP_REMOTE_KEYFILE}" "path=${BACKUP_REMOTE_PATH}" upload_system_backup=1 only_used_for_logs=0 timeout=120 ) if (( dest_count == 1 )); then dest_id="$(jq -r --arg name "$dest_name" --arg legacy "$legacy_dest_name" \ '.data.destination_list[] | select((.name == $name or .name == $legacy) and .type == "SFTP") | .id' \ <<<"$dest_list")" [[ -n "$dest_id" && "$dest_id" != "null" ]] || return 1 whmapi_json_success backup_destination_set "id=${dest_id}" \ "${destination_args[@]}" >>"${LOG_DIR}/backup-config.log" || return 1 log " ✓ Destino SFTP existente atualizado (id ${dest_id})" else dest_json="$(whmapi_json_success backup_destination_add \ "${destination_args[@]}")" || return 1 dest_id="$(jq -r '.data.id // empty' <<<"$dest_json")" [[ -n "$dest_id" ]] || { warn " ✗ API adicionou o destino SFTP sem retornar um id" return 1 } log " ✓ Destino SFTP criado (id ${dest_id})" fi whmapi_json_success backup_destination_validate \ "id=${dest_id}" disableonfail=1 >>"${LOG_DIR}/backup-config.log" || { warn " ✗ validação real do destino SFTP falhou; o destino foi desabilitado" return 1 } dest_list="$(whmapi_json_success backup_destination_list)" || return 1 jq -e --arg id "$dest_id" ' .data.destination_list | map(select(.id == $id and .type == "SFTP" and ((.disabled | tostring) == "0"))) | length == 1' >/dev/null 2>&1 <<<"$dest_list" || { warn " ✗ destino SFTP validado não permaneceu habilitado" return 1 } log " ✓ Destino SFTP validado e habilitado" fi return 0 } # ----- cPHulk: whitelist dos IPs de gestão ------------------------------------- # Usada na fase 2 e no --finish (pós-licenciamento). Retorna 1 se algum IP # não puder ser confirmado; o passo chamador não deve gravar `.done` nesse caso. reload_sshd_after_cphulk() { # read_cphulk_records sinaliza restart_ssh=1 quando o cPanel precisou # desativar UseDNS para que a whitelist por IP seja confiável. Recarregar # sem validar poderia derrubar o acesso remoto por uma configuração alheia. if ! sshd -t >/dev/null 2>&1; then warn " ✗ cPHulk solicitou reload do sshd, mas sshd -t reprovou a configuração" return 1 fi if ! systemctl reload sshd; then warn " ✗ cPHulk solicitou reload do sshd, mas o reload falhou" return 1 fi if ! systemctl is-active --quiet sshd; then warn " ✗ sshd não permaneceu ativo após aplicar a alteração solicitada pelo cPHulk" return 1 fi if ! ss -H -ltnp 2>/dev/null | awk -v port=":${SSH_PORT}" \ '$4 ~ port "$" && $0 ~ /sshd/ {found=1} END {exit !found}'; then warn " ✗ listener do sshd na porta ${SSH_PORT} não foi confirmado após o reload" return 1 fi log " ✓ Alteração de UseDNS do cPHulk aplicada com reload seguro do sshd" } cphulk_whitelist_admin_ips() { local admin_ip create_out list_out warning_ssh rc=0 restart_ssh=0 for admin_ip in ${ADMIN_SRC_IP:-} ${ADMIN_ALLOW_IPS:-}; do is_ipv4 "$admin_ip" || continue create_out="$(whmapi_json_success create_cphulk_record list_name=white ip="$admin_ip" \ comment="admin cpanel-bootstrap" skip_enabled_check=1)" || { /usr/local/cpanel/scripts/cphulkdwhitelist "$admin_ip" >/dev/null 2>&1 || true } list_out="$(whmapi_json_success read_cphulk_records \ list_name=white skip_enabled_check=1)" || list_out="" if [[ -n "$list_out" ]] && \ jq -e '(.data.restart_ssh | tostring) == "1"' \ >/dev/null 2>&1 <<<"$list_out"; then restart_ssh=1 warning_ssh="$(jq -r '.data.warning_ssh // empty' <<<"$list_out" 2>/dev/null || true)" fi if [[ -n "$list_out" ]] && jq -e --arg ip "$admin_ip" ' def scalar_values_and_keys: if type == "object" then (keys[]), (.[] | scalar_values_and_keys) elif type == "array" then .[] | scalar_values_and_keys else tostring end; [.data.ips_in_list | scalar_values_and_keys] | index($ip) != null' \ >/dev/null 2>&1 <<<"$list_out"; then log " ✓ IP de gestão ${admin_ip} confirmado na whitelist do cPHulk" else if [[ -n "$create_out" ]]; then warn " ✗ API respondeu à inclusão de ${admin_ip}, mas a leitura não confirmou o IP" else warn " ✗ não foi possível whitelistar/confirmar ${admin_ip} no cPHulk" fi rc=1 fi done if (( restart_ssh == 1 )); then [[ -z "$warning_ssh" ]] || warn "cPHulk: ${warning_ssh}" reload_sshd_after_cphulk || rc=1 fi return $rc } configure_cphulk() { local status_out [[ "${WHMAPI_OK:-0}" == "1" ]] || return 1 whmapi_json_success enable_cphulk >/dev/null || return 1 status_out="$(whmapi_json_success cphulk_status)" || return 1 jq -e '(.data.is_enabled | tostring) == "1"' \ >/dev/null 2>&1 <<<"$status_out" || return 1 cphulk_whitelist_admin_ips } configure_fpm_default() { local out [[ "${WHMAPI_OK:-0}" == "1" ]] || return 1 whmapi_json_success php_set_default_accounts_to_fpm \ default_accounts_to_fpm=1 >/dev/null || return 1 out="$(whmapi_json_success php_get_default_accounts_to_fpm)" || return 1 jq -e '[.. | objects | .default_accounts_to_fpm? | tostring] | index("1") != null' \ >/dev/null 2>&1 <<<"$out" } configure_cpanel_php_fpm_service() { local out wait_count=0 [[ "${WHMAPI_OK:-0}" == "1" ]] || return 1 whmapi_json_success configureservice \ service=cpanel_php_fpm enabled=1 monitored=1 >/dev/null || return 1 out="$(whmapi_json_success servicestatus service=cpanel_php_fpm)" || return 1 jq -e ' .data.service | any(.name == "cpanel_php_fpm" and ((.installed | tostring) == "1") and ((.enabled | tostring) == "1") and ((.monitored | tostring) == "1"))' \ >/dev/null 2>&1 <<<"$out" || return 1 while (( wait_count < 15 )) && \ ! systemctl is-active --quiet cpanel_php_fpm.service 2>/dev/null; do wait_count=$((wait_count + 1)) sleep 2 done systemctl is-active --quiet cpanel_php_fpm.service 2>/dev/null } postlicense_steps_complete() { local step for step in cpanel_hostname api_tweaks fpm_default cpanel_php_fpm cphulk autossl backup; do [[ -f "${STEP_DIR}/${step}.done" ]] || return 1 done } mark_postlicense_complete() { local marker_tmp="${POSTLICENSE_SUCCESS_MARKER}.tmp.$$" printf 'completed_at=%s\nscript_version=%s\n' \ "$(date '+%F %T %z')" "$VERSION" > "$marker_tmp" || return 1 chmod 0600 "$marker_tmp" || { rm -f "$marker_tmp"; return 1; } mv -f "$marker_tmp" "$POSTLICENSE_SUCCESS_MARKER" || { rm -f "$marker_tmp" return 1 } if ! sync -f "$POSTLICENSE_SUCCESS_MARKER" 2>/dev/null; then rm -f "$POSTLICENSE_SUCCESS_MARKER" return 1 fi } mark_finish_pending() { local reason="$1" marker_tmp="${FINISH_PENDING_MARKER}.tmp.$$" { printf 'pending_at=%s\n' "$(date --iso-8601=seconds)" printf 'reason=%s\n' "$reason" } > "$marker_tmp" || return 1 chmod 0600 "$marker_tmp" || { rm -f "$marker_tmp"; return 1; } mv -f "$marker_tmp" "$FINISH_PENDING_MARKER" } # ----- systemd: units da fase 3 (verificação pós-reboot) ----------------------- # Escritas na FASE 1 (onde o daemon-reload é seguro). As Conditions garantem # que só agem num boot ocorrido APÓS a fase 2 concluir. write_phase3_units() { cat > "$VERIFY_SERVICE_PATH" < "$VERIFY_TIMER_PATH" < "$LICENSE_SERVICE_PATH" < "$LICENSE_TIMER_PATH" </dev/null || true)" write_phase3_units write_license_units units_after="$(cat "$VERIFY_SERVICE_PATH" "$VERIFY_TIMER_PATH" \ "$LICENSE_SERVICE_PATH" "$LICENSE_TIMER_PATH" 2>/dev/null || true)" if [[ "$units_before" != "$units_after" ]]; then systemctl daemon-reload >/dev/null 2>&1 || \ warn "units atualizadas, mas o daemon-reload falhou; execute 'systemctl daemon-reload' manualmente" log " ✓ Units do bootstrap atualizadas para a v${VERSION}" fi } # ============================================================================ # FASE 2 — pós-reboot, executada automaticamente pelo systemd # ============================================================================ run_phase2() { [[ $EUID -eq 0 ]] || die "este script precisa ser executado como root" # O service roda com UMask=0077 (protege os logs que o systemd cria), mas # os arquivos de CONFIG gravados aqui precisam nascer 0644 — um # /etc/my.cnf.d/*.cnf 0600 é silenciosamente IGNORADO pelo mariadbd (que # lê a config como usuário mysql) e o tuning viraria no-op. umask 022 # Serviços systemd NÃO definem $HOME — e o client mysql precisa dele para # achar as credenciais em /root/.my.cnf. Sem isso, toda consulta dá # "Access denied" silencioso que se disfarça de MariaDB fora do ar # (custou 3 rounds de teste para diagnosticar!). export HOME="${HOME:-/root}" install -d -m 0700 "$LOG_DIR" "$STATE_DIR" "$STEP_DIR" if [[ -f "${STATE_DIR}/phase2.done" ]]; then log "Fase 2 já foi concluída anteriormente. Nada a fazer." exit 0 fi if [[ -f "${STATE_DIR}/phase2.halt" ]]; then die "fase 2 suspensa: $(read_phase2_halt_reason). Investigue ${LOG_DIR}/phase2.log" fi # Lock exclusivo: previne execução concorrente caso o timer/service # seja re-disparado por systemctl daemon-reload (como faz o imav-deploy.sh # e scripts similares). Se já houver outra instância rodando, sai limpo. exec 200>"${STATE_DIR}/phase2.lock" if ! flock -n 200; then # exit != 0 de propósito: com exit 0 o oneshot ficaria 'active # (exited)' e o OnUnitInactiveSec do timer não re-agendaria neste # boot. O run_on_failure detecta o lock ativo e não notifica nada. log "Outra instância da fase 2 já está em execução (lock ativo). Saindo." exit 1 fi # Contador de tentativas ANTES de carregar/validar a config: um die # determinístico em load_runtime_config/validate_config também precisa # consumir tentativas — senão o timer notificaria a cada 15 min para # sempre, sem nunca chegar ao phase2.halt. Carga best-effort do # runtime.env (dono root, modo 0600) só para MAX_PHASE2_ATTEMPTS e canais # de notificação; a carga oficial validada acontece logo abaixo. if [[ -f "$RUNTIME_CONFIG_PATH" && ! -L "$RUNTIME_CONFIG_PATH" && \ "$(stat -c '%u %a' "$RUNTIME_CONFIG_PATH" 2>/dev/null)" == "0 600" ]]; then # shellcheck disable=SC1090 source "$RUNTIME_CONFIG_PATH" 2>/dev/null || true fi # Valor corrompido não pode quebrar a aritmética do teto (a validação # oficial ainda não rodou neste ponto). is_integer "$MAX_PHASE2_ATTEMPTS" || MAX_PHASE2_ATTEMPTS=8 local phase2_attempts=0 if [[ -f "${STATE_DIR}/phase2.attempts" ]]; then phase2_attempts="$(cat "${STATE_DIR}/phase2.attempts" 2>/dev/null || echo 0)" is_integer "$phase2_attempts" || phase2_attempts=0 fi phase2_attempts=$((phase2_attempts + 1)) echo "$phase2_attempts" > "${STATE_DIR}/phase2.attempts" if (( phase2_attempts > MAX_PHASE2_ATTEMPTS )); then local phase2_limit_reason phase2_limit_reason="excedeu ${MAX_PHASE2_ATTEMPTS} tentativas das configurações retomáveis da fase 2" write_phase2_halt "$phase2_limit_reason" # Zera o contador: remover o phase2.halt libera um ciclo completo de # novas tentativas (senão a primeira execução pós-halt recriaria o # halt imediatamente, sem executar nada). rm -f "${STATE_DIR}/phase2.attempts" notify "Fase 2 SUSPENSA após ${MAX_PHASE2_ATTEMPTS} tentativas" "${phase2_limit_reason}. Investigue ${LOG_DIR}/phase2.log; após corrigir, remova ${STATE_DIR}/phase2.halt e retome com: systemctl start cpanel-bootstrap.service" die "${phase2_limit_reason} — suspensa via ${STATE_DIR}/phase2.halt" fi load_runtime_config validate_config # Reconfirma o firewall de transição antes de qualquer trabalho: a janela # do instalador (30-90 min) não pode rodar em fail-open silencioso. ensure_gap_firewall_still_active # Classifica o instalador em CLEAN, DONE ou PARTIAL. PARTIAL suspende a # fase sem executar `latest` novamente; DONE recuperado pula direto para # as configurações retomáveis. reconcile_cpanel_installer_state log "===================================================================" log " FASE 2 iniciada (tentativa ${phase2_attempts}/${MAX_PHASE2_ATTEMPTS}, script v${VERSION})" log "===================================================================" # O reboot é o ponto em que cloud-init/metadados do provedor podem tentar # restaurar o hostname original. Enquanto o host ainda está CLEAN, repara # kernel e /etc/hosts antes de o instalador validar o FQDN. Se o cPanel já # foi concluído/recuperado, não usamos hostnamectl: a etapa # cpanel_hostname abaixo deve atualizar também os estados internos pelo # utilitário oficial set_hostname. if [[ ! -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]]; then enforce_expected_hostname || \ die "hostname não permaneceu válido após o reboot ($HOSTNAME_FQDN esperado)" else log "cPanel já validado; eventual divergência de hostname será reconciliada pelo utilitário oficial" fi # --- 0. Aguarda multi-user.target estar totalmente ativo -------------- # Proteção contra race condition com o instalador do cPanel, que aborta # com "multi-user.target is not active" se for chamado cedo demais. log "Aguardando multi-user.target estar ativo..." local wait_count=0 until systemctl is-active --quiet multi-user.target; do wait_count=$((wait_count + 1)) if (( wait_count > 60 )); then die "multi-user.target não ficou ativo em 5 minutos — abortando" fi sleep 5 done log "multi-user.target ativo — prosseguindo" # --- 1. Instalação do cPanel ------------------------------------------- if ! is_cpanel_installed; then log "Baixando e instalando cPanel (tier: $CPANEL_TIER)" log "Isto leva de 30 a 90 minutos. Log detalhado: ${LOG_DIR}/cpanel-install.log" # Baixa em /root/cpanel-install para não poluir /home (que vai ser # populado com contas de usuários pelo cPanel). local install_dir="/root/cpanel-install" local cpanel_install_rc=0 local cpanel_download_rc=0 latest_sha installer_marker_tmp local -a latest_args=(--skip-cloudlinux) [[ "$WP_TOOLKIT_ENABLE" == "1" ]] || latest_args+=(--skip-wptoolkit) [[ "$ANTIVIRUS" == "imunifyav" ]] || latest_args+=(--skip-imunifyav) mkdir -p "$install_dir" cd "$install_dir" || die "não foi possível entrar em $install_dir" # O procedimento oficial para escolher o tier de INSTALAÇÃO é criar # /etc/cpupdate.conf com CPANEL= ANTES de rodar o installer — # sem isso ele instala o tier padrão (release) e, como updates nunca # fazem downgrade, o tier configurado jamais passaria a valer. if [[ -f /etc/cpupdate.conf ]]; then cp -a /etc/cpupdate.conf "${LOG_DIR}/cpupdate.conf.preinstall.bak.$(date +%Y%m%d%H%M%S)" fi write_cpupdate_config # O download é idempotente e pode repetir dentro do curl. O instalador # NÃO é: depois que `sh latest` começa, qualquer falha deixa o host fora # do estado clean exigido pelo cPanel. rm -f latest latest.part prlimit --core=0:0 --fsize=5242880:5242880 -- \ curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ --connect-timeout 20 --max-time 300 --retry 3 --retry-delay 2 \ --retry-max-time 300 --retry-all-errors --max-filesize 5242880 \ -o latest.part https://securedownloads.cpanel.net/latest || cpanel_download_rc=$? if (( cpanel_download_rc != 0 )); then rm -f latest.part die "download do instalador cPanel falhou (exit ${cpanel_download_rc}); o latest não foi executado e a fase 2 pode repetir o download com segurança" fi # O `latest` oficial pode usar payload autoextraível, então não usamos # `sh -n`. TLS, limite de tamanho, arquivo não vazio e shebang válido # são confirmados antes de registrar que a instalação começou. [[ -s latest.part ]] || \ die "download do cPanel ficou vazio; instalador não executado" head -n 1 latest.part | grep -qE '^#!.*(ba)?sh([[:space:]]|$)' || \ die "download do cPanel não possui shebang shell válido; instalador não executado" mv -fT latest.part latest || \ die "não foi possível promover atomicamente o instalador baixado" latest_sha="$(sha256sum latest | awk '{print $1}')" || \ die "não foi possível calcular SHA-256 do instalador cPanel" [[ "$latest_sha" =~ ^[0-9a-f]{64}$ ]] || \ die "SHA-256 calculado para o instalador cPanel é inválido" # Último gate ainda no estado CLEAN. O preflight possui fallback DoH, # mas o cPanel consulta exclusivamente /etc/resolv.conf. Instabilidade # real do resolver sempre bloqueia. NXDOMAIN/A divergente pode seguir: # o cPanel atribui um *.cprapid.com provisório e o watcher restaura o # FQDN desejado depois que o registro A for publicado. local hostname_dns_gate_rc=0 validate_cpanel_hostname_dns_gate 5 5 || hostname_dns_gate_rc=$? case "$hostname_dns_gate_rc" in 0) ;; 2) die "resolver local instável (${CPANEL_HOSTNAME_DNS_STATUS}); latest não executado — corrija /etc/resolv.conf e repita" ;; 3) if [[ "$ALLOW_DNS_MISMATCH" == "1" ]]; then warn "DNS local ainda não aponta ${HOSTNAME_FQDN} para ${SERVER_PUBLIC_IP}; instalação seguirá com hostname provisório se necessário" else die "resolver local não confirmou ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP}; latest não executado" fi ;; *) die "resultado inesperado no gate DNS do hostname: ${hostname_dns_gate_rc}" ;; esac installer_marker_tmp="$(mktemp "${STATE_DIR}/.cpanel-installer.started.XXXXXX")" || \ die "não foi possível reservar o marker de início do instalador" { printf 'started_at=%s\n' "$(date --iso-8601=seconds)" printf 'tier=%s\n' "$CPANEL_TIER" printf 'sha256=%s\n' "$latest_sha" printf 'hostname=%s\n' "$HOSTNAME_FQDN" printf 'hostname_dns_status=%s\n' "$CPANEL_HOSTNAME_DNS_STATUS" printf 'hostname_dns_ipv4=%s\n' "$CPANEL_HOSTNAME_DNS_IPV4" } > "$installer_marker_tmp" || { rm -f "$installer_marker_tmp" die "não foi possível gravar o marker de início do instalador" } chmod 0600 "$installer_marker_tmp" || { rm -f "$installer_marker_tmp" die "não foi possível proteger o marker de início do instalador" } mv -f "$installer_marker_tmp" "$CPANEL_INSTALL_STARTED_MARKER" || \ die "não foi possível publicar o marker de início do instalador" sync -f "$CPANEL_INSTALL_STARTED_MARKER" || \ die "não foi possível sincronizar o marker de início do instalador no disco" log "Executando o instalador oficial do cPanel uma única vez" # 200>&- : o processo longo não herda o fd do flock da fase 2 caso # sobreviva a um encerramento anormal do bootstrap. timeout --signal=TERM --kill-after=10m "$CPANEL_INSTALL_TIMEOUT" \ sh latest "${latest_args[@]}" \ >>"${LOG_DIR}/cpanel-install.log" 2>&1 200>&- || cpanel_install_rc=$? # Runtime é obrigatório. Além dele, aceitamos exit 0 do processo que # acabamos de executar OU uma frase terminal oficial. Assim mudanças # futuras no texto do log não geram falso negativo, sem afrouxar a # recuperação pós-crash (que não possui um exit code confiável). local cpanel_completion_confirmed=0 cpanel_log_complete=0 cpanel_install_log_complete && cpanel_log_complete=1 if (( cpanel_install_rc == 0 || cpanel_log_complete == 1 )); then log "Aguardando o runtime do cPanel confirmar a instalação concluída..." if wait_for_cpanel_runtime_ready 300; then cpanel_install_log_complete && cpanel_log_complete=1 cpanel_completion_confirmed=1 fi fi if (( cpanel_completion_confirmed == 1 )); then touch "$CPANEL_INSTALL_SUCCESS_MARKER" rm -f "$CPANEL_INSTALL_FAILED_MARKER" if (( cpanel_install_rc != 0 )); then warn "installer retornou exit ${cpanel_install_rc}, mas runtime + log oficial confirmaram conclusão" elif (( cpanel_log_complete == 0 )); then warn "installer retornou exit 0 e o runtime está válido, mas nenhuma frase terminal conhecida foi encontrada no log" fi log " ✓ Instalador do cPanel concluído e validado" else local installer_failure_reason installer_failure_reason="instalador do cPanel ainda não pôde ser validado (exit ${cpanel_install_rc}); o latest não será reexecutado. A próxima execução da fase 2 fará somente uma reconciliação final de runtime + log/exit registrado e, se continuar incompleto, suspenderá para restauração do snapshot/SO limpo" { printf 'failed_at=%s\n' "$(date --iso-8601=seconds)" printf 'exit_code=%s\n' "$cpanel_install_rc" printf 'reason=%s\n' "$installer_failure_reason" } > "$CPANEL_INSTALL_FAILED_MARKER" chmod 0600 "$CPANEL_INSTALL_FAILED_MARKER" || true notify "Fase 2 — aguardando reconciliação final do instalador" "$installer_failure_reason" die "$installer_failure_reason" fi else log "cPanel já instalado e validado — pulando instalação" fi # Garante o tier correto para os updates diários daqui em diante write_cpupdate_config grep -qxF "CPANEL=${CPANEL_TIER}" /etc/cpupdate.conf || \ die "tier solicitado não persistiu em /etc/cpupdate.conf" if rpm -q cloudlinux-release >/dev/null 2>&1 || \ ! grep -qi '^ID="\?almalinux"\?' /etc/os-release; then die "o sistema deixou de ser AlmaLinux apesar de --skip-cloudlinux" fi # O instalador executa ensure_hostname_resolves e pode trocar o FQDN por # um hostname automático enquanto o DNS ainda não existe. Reconciliamos # pelo utilitário oficial assim que o registro A estiver pronto. if step_begin cpanel_hostname; then log "Confirmando hostname em todos os estados internos do cPanel" local hostname_reconcile_rc=0 reconcile_cpanel_hostname || hostname_reconcile_rc=$? case "$hostname_reconcile_rc" in 0) step_end ;; 2) step_warn "DNS/hostname pendente: o cPanel está operacional com hostname provisório; o --finish tentará novamente a cada 30 min" ;; *) step_fail "hostname do cPanel não pôde ser reconciliado; veja ${LOG_DIR}/hostname-reconcile.log" ;; esac fi # --- 1a. EPEL obrigatório + ferramentas opcionais pós-cPanel ------------ if step_begin epel_tools; then log "Validando EPEL instalado pelo cPanel e aplicando ferramentas de monitoramento" local monitoring_rc=0 if ensure_epel_ready; then yum --enablerepo=epel install -y screen htop iftop btop nload \ >>"${LOG_DIR}/monitoring-tools.log" 2>&1 || monitoring_rc=$? if (( monitoring_rc == 0 )); then log " ✓ EPEL habilitado e ferramentas instaladas" step_end else step_warn "EPEL está funcional, mas algumas ferramentas opcionais falharam; veja ${LOG_DIR}/monitoring-tools.log" fi else step_fail "EPEL obrigatório não ficou instalado/habilitado após o cPanel; veja ${LOG_DIR}/epel-postinstall.log" fi fi export PATH="/usr/local/cpanel/bin:/usr/local/cpanel/scripts:$PATH" # --- 1a-bis. Licença cPanel / disponibilidade do whmapi1 ---------------- # Sem licença válida o whmapi1 responde "Cannot Read License File" a TUDO, # e os passos via API (tweaks extras, backup, AutoSSL, cPHulk) degradam. # Detectamos cedo, tentamos renovar e avisamos com clareza. log "Verificando licença cPanel / whmapi1" WHMAPI_OK=0 if whmapi_json_success gethostname >/dev/null; then WHMAPI_OK=1 log " ✓ whmapi1 respondendo (licença OK)" else warn "whmapi1 não está respondendo — tentando atualizar a licença (cpkeyclt)" /usr/local/cpanel/cpkeyclt >>"${LOG_DIR}/phase2.log" 2>&1 || true if whmapi_json_success gethostname >/dev/null; then WHMAPI_OK=1 log " ✓ Licença atualizada — whmapi1 respondendo" else # Fluxo NORMAL de entrega: o cliente licencia depois de receber o # servidor. Nada quebra — os passos via API ficam pendentes e o # timer de pós-licenciamento completa tudo sozinho quando a # licença ativar (verificação a cada 30 min). touch "${STATE_DIR}/license.pending" warn "Servidor ainda SEM licença cPanel — instalação segue normalmente" warn " Tweaks/API, PHP-FPM, backup, AutoSSL e cPHulk ficam pendentes e serão" warn " aplicados AUTOMATICAMENTE após o licenciamento (timer a cada 30 min)" warn " Para completar na hora após licenciar: ${INSTALL_PATH} --finish" local license_notice_hostname license_notice_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo "$SERVER_PUBLIC_IP")" notify "Instalado SEM licença (pendências automáticas)" "cPanel instalado e configurado. Hostname atual: ${license_notice_hostname}; desejado: ${HOSTNAME_FQDN}. Quando o IP for licenciado, backup/AutoSSL/cPHulk completam sozinhos (timer 30 min) — ou rode: ${INSTALL_PATH} --finish" fi fi if [[ "$WHMAPI_OK" == "1" ]]; then rm -f "${STATE_DIR}/license.pending" else rm -f "$POSTLICENSE_SUCCESS_MARKER" fi # --- 1b. Aguarda EA-PHP estar disponível e ESTÁVEL (máx 10 min) -------- # O cPanel "termina" o install mas ainda instala os pacotes ea-phpXX em # background, um a um. Detectamos no filesystem (/opt/cpanel/ea-phpXX/) e # exigimos 3 leituras iguais consecutivas (~30s sem mudança) — parar no # primeiro hit deixaria versões instaladas logo depois sem defaults e # sem PHP-FPM. log "Aguardando EA-PHP estar disponível e estável (máx 10 minutos)..." local php_wait_count=0 local php_versions="" php_prev="" php_stable=0 while (( php_wait_count < 60 )); do # Lista diretórios /opt/cpanel/ea-phpNN/ e verifica que o binário PHP existe php_versions=$(find /opt/cpanel -maxdepth 1 -type d -name 'ea-php*' 2>/dev/null \ | awk -F/ '{print $NF}' \ | while read -r v; do [[ -x "/opt/cpanel/$v/root/usr/bin/php" ]] && echo "$v" done | sort -u || true) if [[ -n "$php_versions" && "$php_versions" == "$php_prev" ]]; then php_stable=$((php_stable + 1)) if (( php_stable >= 3 )); then local count count=$(echo "$php_versions" | wc -l) log " ✓ Detectadas $count versão(ões) EA-PHP (conjunto estável): $(echo "$php_versions" | tr '\n' ' ')" break fi else php_stable=0 fi php_prev="$php_versions" php_wait_count=$((php_wait_count + 1)) sleep 10 done if [[ -z "$php_versions" ]]; then warn "Nenhuma versão EA-PHP detectada após 10 minutos." warn "Os defaults PHP via whmapi1 vão pular." warn "Você pode instalar PHP manualmente depois via WHM → EasyApache 4" fi # Calculado aqui porque é usado tanto no CSF (PT_USERPROC) quanto no FPM local fpm_max_children fpm_max_children="$(calculate_php_fpm_max_children)" # --- 2. CSF via pacote oficial cpanel-csf ------------------------------ # A ConfigServer encerrou em 31/08/2025. A cPanel agora mantém o fork # oficial via RPM cpanel-csf (que exige cPanel já instalado). if ! rpm -q cpanel-csf >/dev/null 2>&1; then [[ ! -d /etc/csf ]] || \ die "/etc/csf já existe sem o RPM cpanel-csf; remova/migre a instalação manual antiga antes de continuar" log "Instalando CSF via pacote oficial cpanel-csf" yum install -y cpanel-csf rpm -q cpanel-csf >/dev/null 2>&1 || die "yum concluiu sem instalar o RPM cpanel-csf" # O scriptlet do RPM cpanel-csf habilita e inicia csf+lfd imediatamente # com a config padrão. Em servidores fresh, o lfd costuma falhar nesse # primeiro start (race com rsyslog/permissões/etc) e fica em estado # 'failed' no systemd. Paramos os dois agora para configurar com # tranquilidade e fazemos o start "de verdade" só no final via csf -ra. systemctl stop lfd csf 2>/dev/null || true systemctl reset-failed lfd csf 2>/dev/null || true else log "CSF oficial (RPM cpanel-csf) já instalado — pulando instalação" fi [[ -f "$CSF_CONF" ]] || die "RPM cpanel-csf presente, mas ${CSF_CONF} não existe" # --- 3. Configurar CSF ANTES de mudar a porta SSH --------------------- if step_begin csf_config; then log "Configurando CSF (hardening completo, IPv4 + IPv6)" # Backup do csf.conf antes de qualquer alteração cp -a "$CSF_CONF" "${CSF_CONF}.bak.$(date +%Y%m%d%H%M%S)" # Adiciona a porta SSH customizada (append, não replace) — nas DUAS # famílias: só no TCP_IN deixaria a 22 aberta via IPv6 e a porta nova # bloqueada via IPv6. local csf_list for csf_list in TCP_IN TCP6_IN; do csf_list_add "$csf_list" "$SSH_PORT" "porta SSH ${SSH_PORT}" if [[ "$KEEP_SSH_PORT_22_OPEN" == "1" && "$SSH_PORT" != "22" ]]; then csf_list_add "$csf_list" "22" "porta SSH 22 de segurança" fi # A REMOÇÃO da porta 22 acontece só no passo ssh_port, DEPOIS de # confirmado o listener na porta nova — o RPM cpanel-csf deixa o # csf habilitado no boot, e fechar a 22 antes disso abriria uma # janela de lockout se o servidor reiniciasse no meio. # Libera o range de portas passivas do FTP no firewall csf_list_add "$csf_list" "${FTP_PASV_MIN}:${FTP_PASV_MAX}" "range FTP passive ${FTP_PASV_MIN}:${FTP_PASV_MAX}" done # Sai do modo TESTING (produção) csf_set TESTING "0" # TCP6_IN só tem efeito com o switch mestre IPV6 ligado. Sem ele, o # CSF ignora toda a família v6 — e remover a tabela inet de transição # (que protege as duas famílias) deixaria o IPv6 sem firewall algum. csf_set IPV6 "1" # Corrige o alerta "RESTRICT_SYSLOG is disabled" no WHM # Valor 3 = restringe escrita no syslog apenas ao grupo mysyslog, # evitando que usuários forjem entradas de log e manipulem o lfd csf_set RESTRICT_SYSLOG "3" # Limites de IPs banidos — padrões (200/100) são baixos para servidor # de hospedagem que eventualmente sofre ataques distribuídos csf_set DENY_IP_LIMIT "1000" csf_set DENY_TEMP_IP_LIMIT "500" # Connection Tracking — detecta excesso de conexões simultâneas do # mesmo IP (DDoS simples, scanners, abuso de recursos) csf_set CT_LIMIT "300" csf_set CT_INTERVAL "30" csf_set CT_BLOCK_TIME "1800" # Proteção contra SYN flood: desligada por padrão. A doc do próprio # CSF manda ligar SÓ durante ataque real — o rate-limit é global (não # por IP) e, disparado, derruba conexões legítimas de qualquer origem. # A proteção permanente correta é o tcp_syncookies=1 do sysctl (fase 1). if [[ "$CSF_SYNFLOOD" == "1" ]]; then csf_set SYNFLOOD "1" csf_set SYNFLOOD_RATE "100/s" csf_set SYNFLOOD_BURST "150" else csf_set SYNFLOOD "0" fi # Process Tracking — alertas (não kill) para processos de usuário. # PT_USERPROC precisa de folga acima do pm_max_children do FPM, senão # todo pool que escala legitimamente até o teto vira alerta falso. csf_set PT_USERMEM "512" csf_set PT_USERPROC "$(( fpm_max_children + 15 ))" # Com CSF instalado, ele precisa ser o dono das regras de saída SMTP. # Reloads do CSF podem apagar regras criadas por smtpmailgidonly/WHM. csf_set SMTP_BLOCK "1" if [[ "$CSF_CRITICAL_EMAIL_ALERTS" == "1" ]]; then log "Mantendo alertas críticos do CSF/LFD por e-mail ligados" csf_set CT_EMAIL_ALERT "1" csf_set LF_EMAIL_ALERT "1" csf_set LF_TEMP_EMAIL_ALERT "1" csf_set LF_SSH_EMAIL_ALERT "1" csf_set RT_AUTHRELAY_ALERT "1" csf_set RT_LOCALRELAY_ALERT "1" csf_set RT_LOCALHOSTRELAY_ALERT "1" else log "Desativando alertas por e-mail do CSF/LFD por configuração do bootstrap" csf_set CT_EMAIL_ALERT "0" csf_set LF_EMAIL_ALERT "0" csf_set LF_TEMP_EMAIL_ALERT "0" csf_set LF_SSH_EMAIL_ALERT "0" csf_set RT_AUTHRELAY_ALERT "0" csf_set RT_LOCALRELAY_ALERT "0" csf_set RT_LOCALHOSTRELAY_ALERT "0" fi # E-mail do administrador para alertas do CSF/LFD if [[ -n "${ADMIN_EMAIL:-}" && "$ADMIN_EMAIL" != "root@localhost" ]]; then csf_set LF_ALERT_TO "$ADMIN_EMAIL" fi # Whitelist anti-lockout dos IPs de gestão (csf.allow + csf.ignore) csf_whitelist_admin_ips || step_fail "não foi possível persistir a whitelist de gestão no CSF" grep -qE '^TESTING = "0"' "$CSF_CONF" || step_fail "CSF não persistiu TESTING=0" grep -qE '^IPV6 = "1"' "$CSF_CONF" || step_fail "CSF não persistiu IPV6=1 — sem ele o TCP6_IN é inerte" grep -qE '^RESTRICT_SYSLOG = "3"' "$CSF_CONF" || step_fail "CSF não persistiu RESTRICT_SYSLOG=3" grep -qE '^SMTP_BLOCK = "1"' "$CSF_CONF" || step_fail "CSF não persistiu SMTP_BLOCK=1" for csf_list in TCP_IN TCP6_IN; do csf_list_contains "$csf_list" "$SSH_PORT" || \ step_fail "${csf_list} não contém a porta SSH ${SSH_PORT}" csf_list_contains "$csf_list" "${FTP_PASV_MIN}:${FTP_PASV_MAX}" || \ step_fail "${csf_list} não contém o range FTP passivo" done step_end fi # --- 4. Mudar porta SSH ------------------------------------------------ if step_begin ssh_port; then log "Adicionando porta SSH ${SSH_PORT} por drop-in transacional" local port_dropin="/etc/ssh/sshd_config.d/00-cpanel-bootstrap-port.conf" local port_candidate port_backup="${STATE_DIR}/sshd-port.previous" port_existed=0 install -d -m 0755 /etc/ssh/sshd_config.d # Se outra aplicação já ocupa a porta, aborta antes de tocar no sshd. if [[ "$SSH_PORT" != "22" ]] && \ ss -H -ltnp 2>/dev/null | awk -v port=":${SSH_PORT}" \ '$4 ~ port "$" && $0 !~ /sshd/ {conflict=1} END {exit !conflict}'; then step_fail "porta ${SSH_PORT} já está ocupada por processo que não é o sshd" fi if [[ -f "$port_dropin" ]]; then cp -a "$port_dropin" "$port_backup" port_existed=1 else rm -f "$port_backup" fi port_candidate="$(mktemp /etc/ssh/sshd_config.d/.cpanel-port.XXXXXX)" { echo '# Gerado por cpanel-bootstrap.sh' if [[ "$KEEP_SSH_PORT_22_OPEN" == "1" && "$SSH_PORT" != "22" ]]; then echo 'Port 22' fi echo "Port ${SSH_PORT}" } > "$port_candidate" chmod 0600 "$port_candidate" mv -f "$port_candidate" "$port_dropin" if ! sshd -t; then if (( port_existed == 1 )); then cp -a "$port_backup" "$port_dropin" || \ step_fail "sshd -t falhou e não foi possível restaurar o drop-in anterior; use o console" else rm -f "$port_dropin" fi step_fail "sshd -t reprovou o drop-in de porta; configuração anterior restaurada" fi if ! systemctl reload sshd; then if (( port_existed == 1 )); then cp -a "$port_backup" "$port_dropin" || \ step_fail "reload falhou e não foi possível restaurar o drop-in anterior; use o console" else rm -f "$port_dropin" fi sshd -t || step_fail "reload do sshd falhou e a configuração restaurada é inválida; use o console" if ! systemctl reload sshd >/dev/null 2>&1; then step_fail "reload do sshd falhou também após rollback; use o console" fi step_fail "reload do sshd falhou; configuração de porta anterior restaurada" fi # O retorno do `systemctl reload sshd` não garante que o processo já # fechou/reabriu todos os listeners — uma consulta única e imediata ao # ss podia reverter uma configuração SAUDÁVEL por corrida. Aguarda o # listener por até 15 s antes de decidir pelo rollback. local ssh_listener_ok=0 ssh_listener_wait for (( ssh_listener_wait = 0; ssh_listener_wait < 15; ssh_listener_wait++ )); do if ss -H -ltnp 2>/dev/null | awk -v port=":${SSH_PORT}" \ '$4 ~ port "$" && $0 ~ /sshd/ {found=1} END {exit !found}'; then ssh_listener_ok=1 break fi sleep 1 done if (( ssh_listener_ok == 0 )); then if (( port_existed == 1 )); then cp -a "$port_backup" "$port_dropin" || \ step_fail "listener ausente e não foi possível restaurar o drop-in anterior; use o console" else rm -f "$port_dropin" fi sshd -t || step_fail "listener ausente e configuração restaurada inválida; use o console" if ! systemctl reload sshd >/dev/null 2>&1; then step_fail "listener ausente e reload do rollback falhou; use o console" fi step_fail "sshd não abriu listener próprio na porta ${SSH_PORT}; rollback aplicado" fi log " ✓ sshd confirmado na porta ${SSH_PORT}" if [[ "$KEEP_SSH_PORT_22_OPEN" != "1" && "$SSH_PORT" != "22" ]]; then if ss -H -ltnp 2>/dev/null | awk '$4 ~ /:22$/ && $0 ~ /sshd/ {found=1} END {exit !found}'; then step_fail "KEEP_SSH_PORT_22_OPEN=0, mas outro arquivo sshd_config ainda mantém listener na porta 22" fi local csf_fam for csf_fam in TCP_IN TCP6_IN; do csf_list_remove "$csf_fam" "22" "porta SSH 22" done fi if [[ "$KEEP_SSH_PORT_22_OPEN" == "1" && "$SSH_PORT" != "22" ]]; then log "Porta 22 mantida: valide login EXTERNO na ${SSH_PORT} antes de fechá-la" fi step_end fi # --- 5. Reiniciar CSF com as novas regras ------------------------------ # csf -ra (restart all) reinicia tanto o firewall quanto o lfd com a # config nova. csf -r sozinho só recarrega regras de firewall e deixaria # o lfd parado (que foi parado logo após o yum install acima). log "Reiniciando CSF + LFD com a porta ${SSH_PORT} liberada" local csf_rc=0 csf -ra || csf_rc=$? sleep 5 if (( csf_rc != 0 )) || ! systemctl is-active --quiet lfd 2>/dev/null; then die "CSF/LFD não subiu corretamente — firewall de transição permanece ativo; investigue 'csf -ra'" fi rpm -q cpanel-csf >/dev/null 2>&1 || \ die "pacote cpanel-csf não está instalado após a configuração" rpm -q firewalld >/dev/null 2>&1 && \ die "firewalld reapareceu e conflita com o CSF; firewall de transição permanece ativo" grep -qE '^SMTP_BLOCK = "1"' "$CSF_CONF" || \ die "CSF recarregou sem SMTP_BLOCK=1; firewall de transição permanece ativo" # A tabela de transição protege AMBAS as famílias; o CSF precisa provar # que carregou regras IPv6 antes de ela sair — senão a família v6 ficaria # exposta na troca. Só se aplica quando o kernel tem IPv6 habilitado. if [[ -d /proc/sys/net/ipv6 ]]; then if ! grep -qE '^IPV6 = "1"' "$CSF_CONF"; then # Retomada de instalação configurada por versão antiga do # bootstrap (csf_config.done sem IPV6): corrige aqui mesmo. O # reload da autocura é tão fatal quanto o do caminho principal — # rc engolido aqui deixaria um CSF parcial passar no gate. warn "csf.conf sem IPV6=1 (config de versão anterior) — corrigindo antes do handoff" csf_set IPV6 "1" local csf_v6_rc=0 csf -ra >/dev/null 2>&1 || csf_v6_rc=$? sleep 5 if (( csf_v6_rc != 0 )) || ! systemctl is-active --quiet lfd 2>/dev/null; then die "csf -ra da autocura IPv6 falhou (rc=${csf_v6_rc}); firewall de transição permanece ativo" fi grep -qE '^IPV6 = "1"' "$CSF_CONF" || \ die "não foi possível ligar IPV6=1 no CSF; firewall de transição permanece ativo" fi # Captura em variável (grep -q no pipe + pipefail geraria die falso # por SIGPIPE) e exige chains DO CSF — uma regra '-A' residual de # outro componente não prova que ESTA carga do CSF cobriu o IPv6. local v6_ruleset v6_ruleset="$(ip6tables -S 2>/dev/null || true)" if ! grep -qE '^-N (DENYIN|LOCALINPUT|LOGDROPIN)' <<<"$v6_ruleset"; then die "CSF ativo, mas as chains IPv6 dele não estão carregadas (ip6tables sem DENYIN/LOCALINPUT); firewall de transição permanece ativo — investigue 'csf -ra' e o suporte a ip6tables" fi log " ✓ CSF + LFD ativos com regras IPv4 e IPv6 carregadas" else log " ✓ CSF + LFD ativos (IPv6 desabilitado no kernel — família v6 sem exposição)" fi # Só agora é seguro remover a tabela temporária. teardown_gap_firewall || die "não foi possível remover com segurança o firewall de transição após o CSF assumir" # --- 6. PHP defaults em todas as versões EA4 --------------------------- # Aplica memory_limit, upload_max_filesize, post_max_size e date.timezone. # Usa a API quando licenciada e mantém o php.ini principal como fonte # canônica. Não cria zzz_*.ini: esse arquivo venceria mudanças posteriores # feitas pelo MultiPHP INI Editor e deixaria a UI divergente do runtime. if step_begin php_defaults; then log "Aplicando defaults PHP em todas as versões EA-PHP instaladas" log " memory_limit=${PHP_MEMORY_LIMIT}, upload_max_filesize=${PHP_UPLOAD_MAX_FILESIZE}, post_max_size=${PHP_POST_MAX_SIZE}, date.timezone=$TIMEZONE" if [[ -n "$php_versions" ]]; then local phpver php_ini_main php_bin php_actual for phpver in $php_versions; do log " → $phpver" if [[ "${WHMAPI_OK:-0}" == "1" ]]; then whmapi_json_success php_ini_set_directives version="$phpver" \ "directive-1=memory_limit:${PHP_MEMORY_LIMIT}" \ "directive-2=upload_max_filesize:${PHP_UPLOAD_MAX_FILESIZE}" \ "directive-3=post_max_size:${PHP_POST_MAX_SIZE}" \ "directive-4=date.timezone:${TIMEZONE}" >/dev/null || \ warn " API php_ini_set_directives falhou; aplicando no php.ini canônico" fi php_ini_main="/opt/cpanel/${phpver}/root/etc/php.ini" if [[ -f "$php_ini_main" ]]; then set_ini_directive "$php_ini_main" "memory_limit" "$PHP_MEMORY_LIMIT" set_ini_directive "$php_ini_main" "upload_max_filesize" "$PHP_UPLOAD_MAX_FILESIZE" set_ini_directive "$php_ini_main" "post_max_size" "$PHP_POST_MAX_SIZE" set_ini_directive "$php_ini_main" "date.timezone" "\"${TIMEZONE}\"" log " ✓ ${php_ini_main} atualizado" else step_fail "php.ini principal não encontrado: $php_ini_main" fi # Remove override legado da v2.1, se uma tentativa anterior o criou. rm -f "/opt/cpanel/${phpver}/root/etc/php.d/zzz_cpanel_bootstrap.ini" php_bin="/opt/cpanel/${phpver}/root/usr/bin/php" php_actual="$("$php_bin" -r 'echo ini_get("memory_limit"),"|",ini_get("upload_max_filesize"),"|",ini_get("post_max_size"),"|",ini_get("date.timezone");' 2>/dev/null || true)" [[ "$php_actual" == "${PHP_MEMORY_LIMIT}|${PHP_UPLOAD_MAX_FILESIZE}|${PHP_POST_MAX_SIZE}|${TIMEZONE}" ]] || \ step_fail "runtime ${phpver} não carregou os defaults esperados (atual: ${php_actual:-n/d})" done # Restart PHP-FPM para todas as versões carregarem novos valores /usr/local/cpanel/scripts/restartsrv_apache_php_fpm \ >>"${LOG_DIR}/php-defaults.log" 2>&1 || \ step_fail "falha ao reiniciar PHP-FPM após aplicar php.ini" else step_fail "nenhuma versão EA-PHP detectada após a espera; não é seguro concluir php_defaults" fi step_end fi # --- 6b. Apache: MPM event + PHP-FPM + tuning -------------------------- # Troca o MPM padrão (prefork) para mpm_event (moderno, threaded, eficiente). # Instala PHP-FPM para todas as versões EA-PHP e o define como handler # padrão para novas contas. PHP-FPM roda fora do Apache e se comunica via # mod_proxy_fcgi — é a combinação recomendada em 2026 para shared hosting. # # Observação sobre mod_cgi/mod_cgid: # - mod_cgi só funciona com MPM prefork (fork-per-request) # - mod_cgid funciona com MPMs threaded (event/worker) # O yum shell lida automaticamente com essa troca. if step_begin apache_fpm; then log "Configurando Apache: MPM event + PHP-FPM" # Trocar MPM prefork → event (+ mod_cgid + mod_http2 + mod_proxy_fcgi) log " → Trocando MPM prefork → event via yum shell" local mpm_rc=0 yum shell -y >>"${LOG_DIR}/apache-mpm.log" 2>&1 <<'YUMSHELL' || mpm_rc=$? remove ea-apache24-mod_mpm_prefork install ea-apache24-mod_mpm_event install ea-apache24-mod_cgid install ea-apache24-mod_http2 install ea-apache24-mod_proxy_fcgi install ea-apache24-mod_brotli run YUMSHELL if (( mpm_rc != 0 )); then step_fail "falha na troca de MPM (exit $mpm_rc); veja ${LOG_DIR}/apache-mpm.log" else log " ✓ MPM event instalado (com mod_cgid, mod_http2, mod_proxy_fcgi, mod_brotli)" fi # Instalar PHP-FPM para cada versão EA-PHP detectada if [[ -n "$php_versions" ]]; then local -a fpm_pkgs=() local phpver for phpver in $php_versions; do fpm_pkgs+=("${phpver}-php-fpm") done log " → Instalando PHP-FPM: ${fpm_pkgs[*]}" yum install -y "${fpm_pkgs[@]}" >>"${LOG_DIR}/apache-mpm.log" 2>&1 || \ step_fail "um ou mais pacotes PHP-FPM não instalaram" else step_fail "nenhuma versão EA-PHP disponível para instalar PHP-FPM" fi # Defaults globais do pool PHP-FPM (uso eficiente de memória em shared hosting) # pm = ondemand: processos criados só quando há requests; ideal para servidores # com muitos domínios de baixo/médio tráfego. Admins podem ajustar pm=dynamic # ou static por-domínio via WHM → MultiPHP Manager → Pool Options. mkdir -p /var/cpanel/ApachePHPFPM cat > /var/cpanel/ApachePHPFPM/system_pool_defaults.yaml <>"${LOG_DIR}/apache-mpm.log" 2>&1 || \ step_fail "php_fpm_config --rebuild falhou" # Diretivas gerenciadas pelo WHM Global Configuration vão no datastore # (senão o rebuildhttpdconf as renderiza DEPOIS do nosso include e os # valores do include perdem) log " → Gravando tuning global do Apache no datastore do WHM" apply_apache_global_config || step_fail "falha ao persistir Global Configuration do Apache" # Tuning complementar que o Global Configuration NÃO gerencia fica no # include pre_main_global.conf (oficialmente suportado e preservado # em rebuilds do httpd.conf). log " → Gravando tuning complementar em pre_main_global.conf" mkdir -p /etc/apache2/conf.d/includes cat > /etc/apache2/conf.d/includes/pre_main_global.conf <<'APACHECONF' # ============================================================================ # Tuning gerado por cpanel-bootstrap.sh # Apenas diretivas que o WHM → Apache Global Configuration NÃO gerencia — # as demais (Timeout, KeepAlive, ServerLimit, MaxRequestWorkers, ServerTokens # etc.) são definidas no datastore do WHM e aparecem na UI. # ============================================================================ # ---- MPM event: geometria de threads (não exposta no Global Config) ------- ThreadsPerChild 25 ThreadLimit 64 MinSpareThreads 75 MaxSpareThreads 250 # ---- MPM prefork (fallback caso alguém reverta para prefork) -------------- MinSpareServers 5 MaxSpareServers 10 # ---- HTTP/2 (habilitado quando mod_http2 está presente) ------------------- Protocols h2 http/1.1 APACHECONF log " ✓ /etc/apache2/conf.d/includes/pre_main_global.conf gravado" # Rebuild httpd.conf para incorporar includes e o novo MPM log " → Rebuild do httpd.conf e restart do Apache" /usr/local/cpanel/scripts/rebuildhttpdconf \ >>"${LOG_DIR}/apache-mpm.log" 2>&1 || \ step_fail "rebuildhttpdconf falhou; veja ${LOG_DIR}/apache-mpm.log" # Valida a config antes de reiniciar para não derrubar o Apache if /usr/local/apache/bin/apachectl configtest >>"${LOG_DIR}/apache-mpm.log" 2>&1; then /usr/local/cpanel/scripts/restartsrv_httpd \ >>"${LOG_DIR}/apache-mpm.log" 2>&1 || \ step_fail "falha ao reiniciar Apache; veja ${LOG_DIR}/apache-mpm.log" log " ✓ Apache reiniciado com nova config" else step_fail "apachectl configtest falhou; Apache não foi reiniciado" fi /usr/local/apache/bin/httpd -V 2>/dev/null | grep -qE 'Server MPM:[[:space:]]+event' || \ step_fail "Apache não está efetivamente usando MPM event" step_end fi # --- 6c. PHP-FPM como default (depende da licença/API) ----------------- if step_begin fpm_default; then if [[ "${WHMAPI_OK:-0}" == "1" ]]; then configure_fpm_default || step_fail "não foi possível confirmar PHP-FPM como default para contas novas" log " ✓ PHP-FPM confirmado como default para contas novas" step_end else step_warn "PHP-FPM default pendente de licença; o --finish tentará novamente" fi fi # PHP-FPM interno dos aplicativos do cPanel (phpMyAdmin, Webmail etc.). # É distinto do PHP-FPM das contas e depende da Service Manager API. if step_begin cpanel_php_fpm; then if [[ "${WHMAPI_OK:-0}" == "1" ]]; then configure_cpanel_php_fpm_service || \ step_fail "não foi possível habilitar/monitorar cpanel_php_fpm" log " ✓ cpanel_php_fpm habilitado, monitorado e ativo" step_end else step_warn "cpanel_php_fpm pendente de licença; o --finish tentará novamente" fi fi # --- 7. Tweak Settings recomendados ------------------------------------ if step_begin tweaks; then log "Aplicando Tweak Settings recomendados" apply_core_tweaks || step_fail "não foi possível aplicar/confirmar os Tweak Settings essenciais" step_end fi # --- 7a. Tweaks de segurança de contas ---------------------------------- # DKIM/SPF por padrão (entregabilidade: Gmail/Outlook exigem desde 2024), # jailshell como shell padrão, força mínima de senha e compiladores # restritos a root — os quick wins do WHM Security Advisor. if [[ "$SECURITY_TWEAKS" == "1" ]] && step_begin security_tweaks; then log "Aplicando tweaks de segurança de contas" # Chaves confirmadas no cpanel.config: create_account_dkim / # create_account_spf (as antigas enabledkimforall/enablespfforall # não existem). Validação via get_tweaksetting protege contra # mudança de nome entre versões. apply_security_tweaks || step_fail "não foi possível aplicar/confirmar os Tweak Settings de segurança" step_end fi # Passo separado para não transformar markers antigos de arquivo em # falso sucesso da API quando a licença ativa entre duas tentativas. if step_begin api_tweaks; then if [[ "${WHMAPI_OK:-0}" == "1" ]]; then log "Confirmando novamente os Tweak Settings via WHM API" apply_core_tweaks || step_fail "não foi possível confirmar os Tweak Settings essenciais via API" apply_security_tweaks || step_fail "não foi possível confirmar os Tweak Settings de segurança via API" step_end else step_warn "confirmação dos Tweak Settings via API pendente de licença" fi fi # --- 7b. Locale padrão do servidor — camada complementar ------------- # Além do tweak setting acima, escreve diretamente o arquivo padrão. # IMPORTANTE: ambos os métodos são sobrescritos pelo cabeçalho # Accept-Language do navegador. Se no primeiro acesso ao WHM aparecer # em inglês, é porque seu navegador envia Accept-Language: en. Resolva # com o menu do usuário (topo direito) → Change Language → Português, # ou acesse uma vez com ?locale=$SERVER_LOCALE na URL para gravar o cookie. log "Definindo locale padrão do servidor para $SERVER_LOCALE (arquivo)" if [[ -f "/var/cpanel/locale/${SERVER_LOCALE}.cdb" ]] || \ [[ -f "/var/cpanel/locale/${SERVER_LOCALE}.yaml" ]]; then echo "$SERVER_LOCALE" > /var/cpanel/server_locale chmod 0644 /var/cpanel/server_locale 2>/dev/null || true log " ✓ /var/cpanel/server_locale = $SERVER_LOCALE" else warn "Locale $SERVER_LOCALE não encontrado em /var/cpanel/locale/" fi # --- 8. cPHulk + whitelist (depende da licença/API) -------------------- if step_begin cphulk; then if [[ "${WHMAPI_OK:-0}" == "1" ]]; then configure_cphulk || step_fail "não foi possível habilitar/confirmar o cPHulk e sua whitelist" log " ✓ cPHulk habilitado e IPs de gestão reconciliados" step_end else step_warn "cPHulk/whitelist pendente de licença; o --finish tentará novamente" fi fi # --- 8b. Configurar tamanho do /tmp via tweak securetmp_file_size ----- # cPanel v130+ tem o tweak `securetmp_file_size` (em MB) que define o # tamanho do /tmp. Padrão: 4GB ou 5% do /usr (o que for menor). # ESTRATÉGIA: aplica APENAS o tweak setting. Não para serviços nem roda # securetmp manualmente — o cPanel/securetmp aplica o novo tamanho # automaticamente no próximo reboot do servidor (recomendado no resumo). # # Tamanho: # disco < 100 GB → /tmp = 8 GB # disco >= 100 GB → /tmp = 10 GB (máximo aceito pelo cPanel) normalize_tmp_size_mb log "Aplicando tweak securetmp_file_size = ${TMP_SIZE_MB} MB" # Verifica se /tmp é gerenciado pelo cPanel (precisa ser /dev/loop*) local tmp_device tmp_device="$(df /tmp 2>/dev/null | awk 'NR==2 {print $1}')" if [[ "$tmp_device" != /dev/loop* ]]; then warn " /tmp não é gerenciado pelo cPanel ($tmp_device) — pulando tweak" warn " Ajuste com seu datacenter se necessário" else # Só aplica se a chave existir nesta versão do cPanel (o nome interno # não é documentado publicamente — validamos via get_tweaksetting) apply_tweak_if_exists securetmp_file_size "$TMP_SIZE_MB" log " → Efetivado no próximo reboot do servidor" fi # --- 9. Pure-FTPd: habilitar e configurar portas passivas + TLS -------- if step_begin pureftpd; then log "Habilitando Pure-FTPd (portas passivas ${FTP_PASV_MIN}-${FTP_PASV_MAX}, TLS nível ${FTP_TLS_LEVEL})" local pureftp_setup_rc=0 /usr/local/cpanel/scripts/setupftpserver pure-ftpd --force \ >>"${LOG_DIR}/pureftpd-install.log" 2>&1 || pureftp_setup_rc=$? (( pureftp_setup_rc == 0 )) || step_fail "falha ao instalar Pure-FTPd (exit $pureftp_setup_rc)" # Grava no DATASTORE (/var/cpanel/conf/pureftpd/local) — /etc/pure-ftpd.conf # é regenerado pelo cPanel e edições diretas nele são descartadas. pureftpd_datastore_set PassivePortRange "${FTP_PASV_MIN} ${FTP_PASV_MAX}" pureftpd_datastore_set TLS "$FTP_TLS_LEVEL" if ! ip -4 -o addr show scope global 2>/dev/null | \ awk '{split($4,a,"/"); print a[1]}' | grep -qxF "$SERVER_PUBLIC_IP"; then pureftpd_datastore_set ForcePassiveIP "$SERVER_PUBLIC_IP" log " ✓ NAT detectado: ForcePassiveIP = ${SERVER_PUBLIC_IP}" fi rebuild_pureftpd_config || step_fail "falha ao regenerar configuração do Pure-FTPd" if grep -qE "^[[:space:]]*PassivePortRange[[:space:]]+${FTP_PASV_MIN}[[:space:]]+${FTP_PASV_MAX}" /etc/pure-ftpd.conf 2>/dev/null; then log " ✓ PassivePortRange presente no /etc/pure-ftpd.conf regenerado" else step_fail "PassivePortRange não apareceu no /etc/pure-ftpd.conf regenerado" fi if systemctl restart pure-ftpd 2>/dev/null; then log " ✓ Pure-FTPd configurado e reiniciado" else step_fail "falha ao reiniciar pure-ftpd" fi systemctl is-active --quiet pure-ftpd || step_fail "pure-ftpd não ficou ativo" step_end fi # --- 10. Antivírus exclusivo ------------------------------------------- if step_begin antivirus; then log "Validando política de antivírus: ${ANTIVIRUS}" case "$ANTIVIRUS" in imunifyav) local imav_wait=0 imav_mode="" while (( imav_wait < 30 )) && ! imunifyav_operational; do imav_wait=$((imav_wait + 1)) sleep 10 done imav_mode="$(imunifyav_runtime_mode)" || \ step_fail "ANTIVIRUS=imunifyav, mas serviço/socket do ImunifyAV não ficou operacional em 5 minutos" rpm -q cpanel-clamav >/dev/null 2>&1 && \ step_fail "ClamAV e ImunifyAV foram encontrados juntos; remova um deles" log " ✓ ImunifyAV operacional (${imav_mode})" ;; clamav) if imunifyav_installed; then step_fail "ANTIVIRUS=clamav, mas ImunifyAV está instalado apesar de --skip-imunifyav" fi /usr/local/cpanel/scripts/update_local_rpm_versions \ --edit target_settings.clamav installed \ >>"${LOG_DIR}/clamav-install.log" 2>&1 || \ step_fail "update_local_rpm_versions falhou para ClamAV" local clamav_script="" if [[ -x /usr/local/cpanel/scripts/check_cpanel_pkgs ]]; then clamav_script="/usr/local/cpanel/scripts/check_cpanel_pkgs" elif [[ -x /usr/local/cpanel/scripts/check_cpanel_rpms ]]; then clamav_script="/usr/local/cpanel/scripts/check_cpanel_rpms" fi [[ -n "$clamav_script" ]] || step_fail "check_cpanel_pkgs não encontrado" "$clamav_script" --fix --targets=clamav \ >>"${LOG_DIR}/clamav-install.log" 2>&1 || \ step_fail "instalação do ClamAV falhou" rpm -q cpanel-clamav >/dev/null 2>&1 || \ step_fail "cpanel-clamav não foi instalado" ;; none) if rpm -q cpanel-clamav >/dev/null 2>&1 || imunifyav_installed; then step_fail "ANTIVIRUS=none, mas um antivírus foi instalado" fi ;; esac log " ✓ Política de antivírus confirmada: ${ANTIVIRUS}" step_end fi # --- 12. Tuning do MySQL/MariaDB por RAM -------------------------------- if [[ "$MYSQL_TUNING" == "1" ]] && step_begin mysql_tuning; then write_mysql_tuning || step_fail "tuning do MySQL não pôde ser aplicado e confirmado" step_end fi # --- 13. ModSecurity 2 + OWASP Core Rule Set (WAF) ---------------------- if [[ "$INSTALL_MODSECURITY_CRS" == "1" ]] && step_begin modsecurity; then log "Instalando ModSecurity + OWASP Core Rule Set via EA4" yum install -y ea-apache24-mod_security2 ea-modsec2-rules-owasp-crs \ >>"${LOG_DIR}/modsec-install.log" 2>&1 || \ step_fail "falha ao instalar ModSecurity/OWASP CRS — veja ${LOG_DIR}/modsec-install.log" rpm -q ea-apache24-mod_security2 ea-modsec2-rules-owasp-crs \ >>"${LOG_DIR}/modsec-install.log" 2>&1 || \ step_fail "RPMs do ModSecurity/OWASP CRS não ficaram instalados" # O RPM registra o vendor OWASP3. Com licença ativa, também exigimos # que a API confirme a habilitação; sem licença o RPM continua sendo # validado e o --finish reconciliará os demais passos dependentes. if [[ "${WHMAPI_OK:-0}" == "1" ]]; then whmapi_json_success modsec_enable_vendor vendor_id=OWASP3 \ >/dev/null || step_fail "WHM API não habilitou o vendor OWASP3" fi /usr/local/cpanel/scripts/rebuildhttpdconf \ >>"${LOG_DIR}/modsec-install.log" 2>&1 || \ step_fail "rebuildhttpdconf falhou após instalar ModSecurity" /usr/local/apache/bin/apachectl configtest \ >>"${LOG_DIR}/modsec-install.log" 2>&1 || \ step_fail "configtest falhou após ModSecurity — veja ${LOG_DIR}/modsec-install.log" /usr/local/cpanel/scripts/restartsrv_httpd \ >>"${LOG_DIR}/modsec-install.log" 2>&1 || \ step_fail "Apache não reiniciou após instalar ModSecurity" systemctl is-active --quiet httpd || step_fail "Apache ficou inativo após instalar ModSecurity" /usr/local/apache/bin/httpd -M 2>>"${LOG_DIR}/modsec-install.log" | \ grep -q 'security2_module' || step_fail "módulo security2 não está carregado no Apache" rpm -ql ea-modsec2-rules-owasp-crs 2>/dev/null | \ grep -qE '/OWASP3(/|$)' || step_fail "arquivos do vendor OWASP3 não foram encontrados" log " ✓ ModSecurity + OWASP CRS instalados, carregados e validados" step_end fi # --- 14. AutoSSL (Let's Encrypt) + certificado do hostname --------------- if step_begin autossl; then if [[ "${WHMAPI_OK:-1}" == "1" ]]; then configure_autossl || step_fail "não foi possível configurar/confirmar o provider AutoSSL" step_end else step_warn "AutoSSL pendente de licença; o --finish tentará novamente" fi fi # --- 15. Backup automático ---------------------------------------------- if step_begin backup; then if [[ "${WHMAPI_OK:-1}" == "1" ]]; then configure_backups || step_fail "backup local/remoto não pôde ser configurado e confirmado" step_end else step_warn "backup pendente de licença; o --finish tentará novamente" fi fi # --- 16. WHM: contato, alias de root e nameservers ----------------------- if step_begin whm_basic; then log "Configurando contato do WHM e nameservers padrão" # O cPanel assume eth0 quando ETHDEV está vazio. Em VMware/modern cloud # a interface costuma ser ens160/ens18 e o cpipv6 falha no reboot. ensure_wwwacct_ethdev || \ step_fail "não foi possível confirmar ETHDEV no /etc/wwwacct.conf" if [[ -n "${ADMIN_EMAIL:-}" && "$ADMIN_EMAIL" != "root@localhost" ]]; then # API oficial (best-effort) + escrita direta no wwwacct.conf (garantia) whmapi1 update_contact_email contact_email="$ADMIN_EMAIL" >/dev/null 2>&1 || true wwwacct_set CONTACTEMAIL "$ADMIN_EMAIL" # Alias de root: alertas do sistema (cron, disco, etc.) chegam ao admin if [[ -f /etc/aliases ]]; then if grep -qE '^root:' /etc/aliases; then sed -i -E "s|^root:.*|root: ${ADMIN_EMAIL}|" /etc/aliases else echo "root: ${ADMIN_EMAIL}" >> /etc/aliases fi command -v newaliases >/dev/null 2>&1 && newaliases 2>/dev/null || true log " ✓ Alias de root → ${ADMIN_EMAIL}" fi else log " ADMIN_EMAIL não definido — contato do WHM fica para configuração manual" fi if [[ -n "$WHM_NS1" ]]; then # API oficial documentada para o wwwacct.conf de nameservers if whmapi1 update_nameservers_config nameserver="$WHM_NS1" \ ${WHM_NS2:+nameserver2="$WHM_NS2"} 2>/dev/null | grep -qE '^[[:space:]]*result: 1'; then log " ✓ Nameservers definidos via update_nameservers_config (${WHM_NS1} ${WHM_NS2})" else wwwacct_set NS "$WHM_NS1" [[ -n "$WHM_NS2" ]] && wwwacct_set NS2 "$WHM_NS2" fi else log " WHM_NS1/WHM_NS2 não definidos — nameservers ficam para o Basic Setup" fi step_end fi # --- 17. Extras opcionais ------------------------------------------------ if [[ "$NGINX_PROXY_ENABLE" == "1" ]] && step_begin ea_nginx; then log "Instalando ea-nginx (reverse proxy com cache na frente do Apache)" if yum install -y ea-nginx ea-nginx-http2 >>"${LOG_DIR}/ea-nginx.log" 2>&1 && \ rpm -q ea-nginx ea-nginx-http2 >>"${LOG_DIR}/ea-nginx.log" 2>&1 && \ nginx -t >>"${LOG_DIR}/ea-nginx.log" 2>&1 && \ systemctl is-active --quiet nginx; then log " ✓ ea-nginx + HTTP/2 instalados e ativos (WHM → NGINX Manager)" step_end else warn " ✗ ea-nginx falhou na instalação/validação — removendo para não derrubar o Apache" yum remove -y ea-nginx-http2 ea-nginx >>"${LOG_DIR}/ea-nginx.log" 2>&1 || true /usr/local/cpanel/scripts/rebuildhttpdconf >>"${LOG_DIR}/ea-nginx.log" 2>&1 || true /usr/local/cpanel/scripts/restartsrv_httpd >>"${LOG_DIR}/ea-nginx.log" 2>&1 || true step_warn "ea-nginx opcional não foi mantido; veja ${LOG_DIR}/ea-nginx.log" fi fi if [[ "$REDIS_ENABLE" == "1" ]] && step_begin redis; then log "Instalando Redis local (object cache para os sites)" yum install -y redis >>"${LOG_DIR}/redis-install.log" 2>&1 || \ step_fail "falha ao instalar Redis" local redis_conf="/etc/redis/redis.conf" [[ -f "$redis_conf" ]] || redis_conf="/etc/redis.conf" [[ -f "$redis_conf" ]] || step_fail "redis.conf não encontrado" local mem_mb redis_max mem_mb="$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo)" if ! is_integer "$mem_mb" || (( mem_mb < 8192 )); then redis_max="256mb" elif (( mem_mb < 16384 )); then redis_max="512mb" else redis_max="1gb"; fi set_redis_directive() { local key="$1" value="$2" if grep -qE "^[#[:space:]]*${key}[[:space:]]+" "$redis_conf"; then sed -i -E "s|^[#[:space:]]*${key}[[:space:]]+.*|${key} ${value}|" "$redis_conf" else printf '%s %s\n' "$key" "$value" >> "$redis_conf" fi } # Prefixo '-' torna o bind IPv6 opcional quando o kernel/provedor # desabilita ::1, sem abrir o serviço fora do loopback IPv4. set_redis_directive bind "127.0.0.1 -::1" set_redis_directive protected-mode yes set_redis_directive requirepass "$REDIS_PASSWORD" set_redis_directive maxmemory "$redis_max" set_redis_directive maxmemory-policy allkeys-lru systemctl enable --now redis >>"${LOG_DIR}/redis-install.log" 2>&1 || \ step_fail "Redis instalado, mas não iniciou" systemctl restart redis >>"${LOG_DIR}/redis-install.log" 2>&1 || \ step_fail "Redis não reiniciou com a configuração protegida" systemctl is-active --quiet redis || step_fail "Redis não ficou ativo" if timeout 5 redis-cli ping 2>/dev/null | grep -qx PONG; then step_fail "Redis respondeu sem autenticação; requirepass não está efetivo" fi REDISCLI_AUTH="$REDIS_PASSWORD" timeout 5 redis-cli --no-auth-warning ping 2>/dev/null | \ grep -qx PONG || step_fail "Redis não respondeu com a senha configurada" ss -ltn 2>/dev/null | awk '$4 ~ /:6379$/ {print $4}' | \ grep -Evq '^127\.0\.0\.1:6379$|^\[::1\]:6379$' && \ step_fail "Redis está exposto fora de loopback na porta 6379" log " ✓ Redis protegido em loopback (senha obrigatória, maxmemory ${redis_max})" step_end fi # --- 18. Quotas --------------------------------------------------------- # fixquotas é recomendado pelo cPanel para corrigir/garantir quotas. # Mantemos como etapa não fatal porque alguns ambientes VPS/filesystems # exigem ajustes manuais ou reboot/remount. log "Verificando/corrigindo quotas do sistema" if [[ -x /usr/local/cpanel/scripts/fixquotas ]]; then local fixquotas_rc=0 local fixquotas_log="${LOG_DIR}/fixquotas.log" : > "$fixquotas_log" timeout --signal=TERM --kill-after=2m 1800 \ /usr/local/cpanel/scripts/fixquotas \ >>"$fixquotas_log" 2>&1 || fixquotas_rc=$? if (( fixquotas_rc == 0 )); then echo "ok" > "${STATE_DIR}/fixquotas.status" log " ✓ fixquotas concluído" elif grep -qi 'Quotas have been enabled and updated' "$fixquotas_log" \ && grep -qi 'XFS.*filesystem quotas' "$fixquotas_log"; then echo "reboot" > "${STATE_DIR}/fixquotas.status" log " ✓ fixquotas atualizou quotas; reboot necessário para ativar quotas XFS" else echo "erro:${fixquotas_rc}" > "${STATE_DIR}/fixquotas.status" warn "fixquotas retornou erro (exit $fixquotas_rc) — veja $fixquotas_log" fi else echo "missing" > "${STATE_DIR}/fixquotas.status" warn "Script /usr/local/cpanel/scripts/fixquotas não encontrado — pulando quotas" fi # --- 19. Gera artefatos finais e só então marca a fase concluída ------- # IMPORTANTE: summary/MOTD precisam existir ANTES de phase2.done; caso # contrário uma falha de disco poderia bloquear o retry pelo Condition. # Tudo isso ainda ocorre antes do cleanup systemd. Fazer daemon-reload # ainda está em execução causou travamento no resumo em testes (provável # interferência com os descritores StandardOutput=append). generate_summary chmod 0600 "$SUMMARY_FILE" || die "não foi possível proteger ${SUMMARY_FILE}" write_motd if [[ ! -f "${STATE_DIR}/license.pending" && \ ! -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then postlicense_steps_complete || \ die "licença ativa, mas há passos de pós-licenciamento sem confirmação" mark_postlicense_complete || \ die "não foi possível persistir a conclusão do pós-licenciamento" rm -f "$FINISH_PENDING_MARKER" else # Instalação principal concluída. A licença e/ou o DNS do hostname são # dependências externas e serão reconciliados pelo watcher de --finish. rm -f "$POSTLICENSE_SUCCESS_MARKER" mark_finish_pending "licença e/ou DNS/hostname" || \ die "não foi possível persistir a pendência do watcher de finalização" fi rm -f "${STATE_DIR}/phase2.attempts" "${STATE_DIR}/phase2.failed" "${STATE_DIR}/current_step" # Registra em qual boot a fase 2 terminou: a fase 3 valida estado # pós-reboot e o --finish usa isso para não dispará-la no mesmo boot. cat /proc/sys/kernel/random/boot_id > "${STATE_DIR}/phase2.boot_id" 2>/dev/null || \ warn "não foi possível registrar o boot_id da fase 2" touch "${STATE_DIR}/phase2.done" # --- 20. Desabilita o timer e o serviço oneshot ----------------------- # Apenas `disable` — não removemos os arquivos unit nem fazemos # daemon-reload (ambos já disparam bug de travamento quando o próprio # serviço invoca systemctl). ConditionPathExists=!phase2.done impede # qualquer re-fire. Admin pode remover manualmente se quiser: # rm -f /etc/systemd/system/cpanel-bootstrap.{service,timer} # systemctl daemon-reload log "Desabilitando o timer e o serviço systemd do bootstrap" systemctl disable cpanel-bootstrap.timer >/dev/null 2>&1 || true systemctl disable cpanel-bootstrap.service >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap.timer >/dev/null 2>&1 || true # O watcher só fica armado se houver licença ou hostname/DNS pendente. if [[ ! -f "${STATE_DIR}/license.pending" && \ ! -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then systemctl disable cpanel-bootstrap-license.timer >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-license.timer >/dev/null 2>&1 || true else systemctl enable --now cpanel-bootstrap-license.timer >/dev/null 2>&1 || \ warn "não foi possível manter o watcher de finalização habilitado" fi log "===================================================================" log " FASE 2 concluída. Bootstrap finalizado." log " Resumo em: $SUMMARY_FILE" log "===================================================================" log "" warn "⚠ REBOOT RECOMENDADO antes de colocar o servidor em produção" warn " - Efetiva o redimensionamento do /tmp para ${TMP_SIZE_MB} MB" warn " - Garante que sysctl/SELinux/firewalld estão consistentes no boot" warn " - Dispara a FASE 3 (verificação automática, ~3 min após o boot)" warn " Comando: shutdown -r now" log "" local phase2_access_hostname phase2_access_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo "$SERVER_PUBLIC_IP")" notify "Fase 2 concluída com sucesso ✅" "cPanel instalado e configurado. Hostname atual: ${phase2_access_hostname}; desejado: ${HOSTNAME_FQDN}. Resumo: ${SUMMARY_FILE}. Reboot recomendado — a fase 3 valida tudo automaticamente após o boot. WHM: https://${phase2_access_hostname}:2087 (ou https://${SERVER_PUBLIC_IP}:2087)" } # Units opcionais que o cPanel anexa a cpanel.service podem tentar iniciar no # boot mesmo quando a própria configuração as mantém disabled. O resultado 2 # deixa systemd degraded sem representar falha de um recurso habilitado. reset_expected_disabled_cpanel_failures() { local unit enabled_state for unit in cpanel_php_fpm.service cpgreylistd.service; do systemctl is-failed --quiet "$unit" 2>/dev/null || continue enabled_state="$(systemctl is-enabled "$unit" 2>/dev/null || true)" if [[ "$enabled_state" == "disabled" ]]; then systemctl reset-failed "$unit" >/dev/null 2>&1 || return 1 log " ✓ Estado failed limpo para unit opcional desabilitada: ${unit}" fi done } remaining_failed_systemd_units() { # cpanel-bootstrap-license é o watcher de --finish: um die dele (licença # indisponível, lock ocupado) não é falha do servidor e o próprio timer o # re-executa; contá-lo aqui envenenaria a fase 3 até phase3.halt. # list-units --state=failed também devolve units desabilitadas ou # transitórias (systemd-run, falha antiga sem reset) — nada disso é # problema de um recurso que o servidor dependa no boot; o filtro por # is-enabled mantém a promessa "unit habilitada" do check da fase 3. local unit enabled_state while IFS= read -r unit; do [[ -n "$unit" ]] || continue # is-enabled imprime o estado E sai com rc != 0 para disabled/masked; # um `|| echo` na mesma substituição capturaria as DUAS saídas # ('disabled\nindeterminado') e o case nunca casaria. enabled_state="$(systemctl is-enabled "$unit" 2>/dev/null)" || true enabled_state="${enabled_state%%$'\n'*}" [[ -n "$enabled_state" ]] || enabled_state=indeterminado case "$enabled_state" in disabled|masked|masked-runtime|transient|indeterminado) continue ;; esac printf '%s\n' "$unit" done < <(systemctl list-units --state=failed --no-legend --plain 2>/dev/null | \ awk '{print $1}' | \ grep -Ev '^(cpanel-bootstrap-verify|cpanel-bootstrap-license|cpipv6)\.service$' || true) } # ============================================================================ # FASE 3 — verificação automática pós-reboot # ============================================================================ run_phase3() { [[ $EUID -eq 0 ]] || die "este script precisa ser executado como root" umask 022 # $HOME ausente no systemd quebra a auth do client mysql (ver fase 2) export HOME="${HOME:-/root}" install -d -m 0700 "$LOG_DIR" "$STATE_DIR" "$STEP_DIR" load_runtime_config [[ -f "${STATE_DIR}/phase2.done" ]] || die "fase 2 ainda não concluída — nada para verificar" if [[ -f "${STATE_DIR}/phase3.done" ]]; then log "Fase 3 já executada anteriormente. Nada a fazer." exit 0 fi [[ ! -f "${STATE_DIR}/phase3.halt" ]] || \ die "fase 3 suspensa; corrija ${PHASE3_REPORT} e remova ${STATE_DIR}/phase3.halt antes de repetir" # A fase 3 valida o estado PÓS-REBOOT (/tmp, sysctl, boot limpo). A guarda # de boot vive AQUI, não só no refresh do --finish: um `--phase3` manual ou # um start avulso da unit ainda no boot da fase 2 gravaria phase3.done e # anularia a validação real. Sai limpo, sem consumir tentativa; o próximo # boot dispara via OnBootSec do timer. (Marker ausente = instalação de # versão anterior; comportamento antigo preservado.) local phase3_current_boot phase3_install_boot phase3_current_boot="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)" phase3_install_boot="$(cat "${STATE_DIR}/phase2.boot_id" 2>/dev/null || true)" if [[ -n "$phase3_current_boot" && "$phase3_current_boot" == "$phase3_install_boot" ]]; then log "Servidor ainda no boot em que a fase 2 concluiu — a verificação só vale após o reboot." log "Reinicie (shutdown -r now) e a fase 3 rodará sozinha ~3 min depois do boot." exit 0 fi exec 205>"${STATE_DIR}/phase3.lock" if ! flock -w 1200 205; then log "Outra operação ainda altera/verifica o servidor; fase 3 será tentada novamente." # A unit usa RemainAfterExit=yes: exit 0 a deixaria active (exited) e # impediria OnUnitInactiveSec de reagendar a verificação. exit 1 fi local phase3_attempt=0 if [[ -f "${STATE_DIR}/phase3.attempts" ]]; then phase3_attempt="$(cat "${STATE_DIR}/phase3.attempts" 2>/dev/null || echo 0)" is_integer "$phase3_attempt" || phase3_attempt=0 fi phase3_attempt=$((phase3_attempt + 1)) printf '%s\n' "$phase3_attempt" > "${STATE_DIR}/phase3.attempts" log "===================================================================" log " FASE 3 — verificação pós-reboot (script v${VERSION})" log "===================================================================" local pass=0 fail=0 pending_checks=0 retry_pending_checks=0 report="" check_result() { local name="$1" ok="$2" detail="$3" case "$ok" in ok) report+=" [OK] ${name}: ${detail}"$'\n' pass=$((pass + 1)) ;; pending) report+=" [PEND.] ${name}: ${detail}"$'\n' pending_checks=$((pending_checks + 1)) ;; retry) report+=" [PEND.] ${name}: ${detail}"$'\n' pending_checks=$((pending_checks + 1)) retry_pending_checks=$((retry_pending_checks + 1)) ;; *) report+=" [FALHA] ${name}: ${detail}"$'\n' fail=$((fail + 1)) ;; esac } reset_expected_disabled_cpanel_failures || \ check_result "units opcionais desabilitadas" fail "não foi possível limpar estados failed esperados" # 1. /tmp redimensionado (o motivo principal do reboot recomendado) normalize_tmp_size_mb local tmp_actual_mb tmp_actual_mb="$(df -Pm /tmp 2>/dev/null | awk 'NR==2 {print $2}')" if is_integer "$tmp_actual_mb" && (( tmp_actual_mb >= TMP_SIZE_MB * 90 / 100 )); then check_result "/tmp" ok "${tmp_actual_mb} MB (alvo ${TMP_SIZE_MB} MB)" else check_result "/tmp" fail "${tmp_actual_mb:-n/d} MB — alvo era ${TMP_SIZE_MB} MB (tweak securetmp pode não ter aplicado)" fi # 2. Serviços essenciais local svc for svc in httpd exim dovecot crond lfd; do if systemctl is-active --quiet "$svc" 2>/dev/null; then check_result "serviço ${svc}" ok "ativo" else check_result "serviço ${svc}" fail "inativo — systemctl status ${svc}" fi done local mysql_svc="" cand for cand in mariadb mysqld mysql; do if systemctl is-active --quiet "$cand" 2>/dev/null; then mysql_svc="$cand" break fi done if [[ -n "$mysql_svc" ]]; then check_result "serviço MySQL/MariaDB" ok "ativo (${mysql_svc})" else check_result "serviço MySQL/MariaDB" fail "nenhum de mariadb/mysqld/mysql ativo" fi # Tuning do MySQL realmente em vigor? (o drop-in pode existir mas ter # sido ignorado — ex.: permissão errada, ou valor sobrescrito no my.cnf) if [[ "$MYSQL_TUNING" == "1" && -f /etc/my.cnf.d/zz-cpanel-bootstrap.cnf && -n "$mysql_svc" ]]; then # Logo após o boot o MariaDB pode ficar minutos indisponível: em host # VMware com relógio errado, o salto de hora do boot faz o chkservd # reiniciá-lo (~6-7 min até o socket voltar, medido em teste real). # Esperamos até 5 min em vez de acusar falso negativo. local bp_now="" bp_try=0 while (( bp_try < 20 )); do bp_now="$(timeout 10 mysql -N -B -e 'SELECT @@innodb_buffer_pool_size' 2>/dev/null || true)" [[ -n "$bp_now" ]] && break bp_try=$((bp_try + 1)) sleep 15 done if is_integer "$bp_now" && (( bp_now > 134217728 )); then check_result "MySQL tuning" ok "innodb_buffer_pool_size = $(( bp_now / 1024 / 1024 )) MB" elif [[ -z "$bp_now" ]]; then # Mostra o erro REAL do client (Access denied ≠ servidor fora do ar) local bp_err bp_err="$(timeout 10 mysql -N -B -e 'SELECT 1' 2>&1 >/dev/null | tail -1)" check_result "MySQL tuning" fail "sem resposta do MariaDB em 5 min (erro: ${bp_err:-desconhecido}) — confirme: mysql -e 'SELECT @@innodb_buffer_pool_size'" else check_result "MySQL tuning" fail "buffer pool no default (${bp_now} bytes) — confira /etc/my.cnf.d/zz-cpanel-bootstrap.cnf e o [mysqld] do /etc/my.cnf" fi local mysql_nofile mysql_nofile="$(systemctl show "$mysql_svc" -p LimitNOFILE --value 2>/dev/null || echo 0)" if is_integer "$mysql_nofile" && (( mysql_nofile >= 65535 )); then check_result "MariaDB file descriptors" ok "LimitNOFILE=${mysql_nofile}" else check_result "MariaDB file descriptors" fail "LimitNOFILE=${mysql_nofile:-n/d}; esperado >= 65535" fi fi if systemctl is-active --quiet pure-ftpd 2>/dev/null; then check_result "serviço pure-ftpd" ok "ativo" else check_result "serviço pure-ftpd" fail "inativo" fi if /usr/local/apache/bin/httpd -V 2>/dev/null | grep -qE 'Server MPM:[[:space:]]+event'; then check_result "Apache MPM" ok "event" else check_result "Apache MPM" fail "MPM event não confirmado" fi if [[ "$INSTALL_MODSECURITY_CRS" == "1" ]]; then if /usr/local/apache/bin/httpd -M 2>/dev/null | grep -q 'security2_module' && \ rpm -q ea-modsec2-rules-owasp-crs >/dev/null 2>&1; then check_result "ModSecurity/OWASP" ok "módulo e ruleset presentes" else check_result "ModSecurity/OWASP" fail "módulo ou ruleset ausente" fi fi if [[ "$NGINX_PROXY_ENABLE" == "1" ]]; then if systemctl is-active --quiet nginx 2>/dev/null && nginx -t >/dev/null 2>&1; then check_result "ea-nginx" ok "ativo e configuração válida" else check_result "ea-nginx" fail "inativo ou nginx -t falhou" fi fi if [[ "$REDIS_ENABLE" == "1" ]]; then local redis_post_ok=1 systemctl is-active --quiet redis 2>/dev/null || redis_post_ok=0 if ! REDISCLI_AUTH="$REDIS_PASSWORD" timeout 5 \ redis-cli --no-auth-warning ping 2>/dev/null | grep -qx PONG; then redis_post_ok=0 fi if timeout 5 redis-cli ping 2>/dev/null | grep -qx PONG; then redis_post_ok=0 fi if ss -ltn 2>/dev/null | awk '$4 ~ /:6379$/ {print $4}' | \ grep -Evq '^127\.0\.0\.1:6379$|^\[::1\]:6379$'; then redis_post_ok=0 fi if (( redis_post_ok == 1 )); then check_result "Redis" ok "ativo, senha obrigatória e somente em loopback" else check_result "Redis" fail "serviço, autenticação ou bind loopback divergiu" fi fi # 3. SSH na porta configurada if ss -tln 2>/dev/null | awk -v port=":${SSH_PORT}" '$4 ~ port "$" {found=1} END {exit !found}'; then check_result "SSH porta ${SSH_PORT}" ok "listener ativo" else check_result "SSH porta ${SSH_PORT}" fail "sem listener — confira ss -tlnp e sshd_config" fi if [[ "$KEEP_SSH_PORT_22_OPEN" == "0" && "$SSH_PORT" != "22" ]] && \ ss -H -ltnp 2>/dev/null | awk '$4 ~ /:22$/ && $0 ~ /sshd/ {found=1} END {exit !found}'; then check_result "SSH porta 22" fail "listener ainda ativo apesar de KEEP_SSH_PORT_22_OPEN=0" elif [[ "$KEEP_SSH_PORT_22_OPEN" == "1" && "$SSH_PORT" != "22" ]] && \ ! ss -H -ltnp 2>/dev/null | awk '$4 ~ /:22$/ && $0 ~ /sshd/ {found=1} END {exit !found}'; then check_result "SSH porta 22" fail "porta de segurança deveria permanecer ativa" fi local expected_ethdev configured_ethdev expected_ethdev="$(detect_primary_ipv4_interface 2>/dev/null || true)" configured_ethdev="$(awk '$1 == "ETHDEV" { print $2; exit }' /etc/wwwacct.conf 2>/dev/null || true)" if [[ -n "$expected_ethdev" && "$configured_ethdev" == "$expected_ethdev" ]]; then check_result "interface do cPanel" ok "ETHDEV=${configured_ethdev}" else check_result "interface do cPanel" fail "ETHDEV=${configured_ethdev:-vazio}; esperado ${expected_ethdev:-não detectado}" fi if systemctl is-enabled --quiet cpipv6.service 2>/dev/null; then if systemctl is-active --quiet cpipv6.service 2>/dev/null; then check_result "serviço cpipv6" ok "ativo" else check_result "serviço cpipv6" fail "unit habilitada, porém inativa/failed — confira ETHDEV e systemctl status cpipv6" fi fi local failed_systemd_units failed_systemd_units="$(remaining_failed_systemd_units | paste -sd, -)" if [[ -z "$failed_systemd_units" ]]; then check_result "systemd" ok "nenhuma unit habilitada inesperadamente em failed" else check_result "systemd" fail "units em failed: ${failed_systemd_units}" fi # 4. WHM acessível (cpsrvd na 2087) if ss -tln 2>/dev/null | awk '$4 ~ /:2087$/ {found=1} END {exit !found}'; then check_result "WHM (porta 2087)" ok "cpsrvd escutando" else check_result "WHM (porta 2087)" fail "cpsrvd não está escutando na 2087" fi # 5. SELinux e firewalld consistentes após o boot local selinux_now selinux_now="$(getenforce 2>/dev/null || echo 'indisponível')" if [[ "$selinux_now" == "Disabled" ]]; then check_result "SELinux" ok "$selinux_now" else check_result "SELinux" fail "$selinux_now (no AlmaLinux 9 o bootstrap exige Disabled após o reboot)" fi if rpm -q firewalld >/dev/null 2>&1 || systemctl is-active --quiet firewalld 2>/dev/null; then check_result "firewalld" fail "pacote presente ou serviço ativo — deve estar removido antes do CSF" else check_result "firewalld" ok "pacote ausente e serviço inativo" fi if [[ -f "${STATE_DIR}/gap-firewall.active" ]] || \ systemctl is-active --quiet cpanel-bootstrap-firewall.service 2>/dev/null; then check_result "firewall de transição" fail "serviço temporário do bootstrap ainda ativo — o CSF deveria ter assumido" else check_result "firewall de transição" ok "removido (CSF no controle)" fi # 6. CSF em produção if grep -qE '^TESTING = "0"' "$CSF_CONF" 2>/dev/null && systemctl is-active --quiet lfd 2>/dev/null; then check_result "CSF/LFD" ok "TESTING=0 e lfd ativo" else check_result "CSF/LFD" fail "confira TESTING no csf.conf e systemctl status lfd" fi if grep -qE '^SMTP_BLOCK = "1"' "$CSF_CONF" 2>/dev/null && \ grep -qx 'smtpmailgidonly=0' "$CPANEL_CONFIG" 2>/dev/null; then check_result "política SMTP" ok "CSF SMTP_BLOCK=1 e SMTP Restrictions do WHM desativado" else check_result "política SMTP" fail "esperado SMTP_BLOCK=1 e smtpmailgidonly=0" fi local antivirus_count=0 antivirus_detail="" imav_phase3_mode="" if imunifyav_installed; then antivirus_count=$((antivirus_count + 1)) antivirus_detail="ImunifyAV" fi if rpm -q cpanel-clamav >/dev/null 2>&1; then antivirus_count=$((antivirus_count + 1)) antivirus_detail="${antivirus_detail:+${antivirus_detail} + }ClamAV" fi case "$ANTIVIRUS" in none) if (( antivirus_count == 0 )); then check_result "antivírus" ok "nenhum, conforme ANTIVIRUS=none" else check_result "antivírus" fail "encontrado ${antivirus_detail}, mas ANTIVIRUS=none" fi ;; imunifyav) imav_phase3_mode="$(imunifyav_runtime_mode 2>/dev/null || true)" if (( antivirus_count == 1 )) && [[ "$antivirus_detail" == "ImunifyAV" ]] && \ [[ -n "$imav_phase3_mode" ]]; then check_result "antivírus" ok "ImunifyAV exclusivo (${imav_phase3_mode})" else check_result "antivírus" fail "esperado ImunifyAV exclusivo e RPC/serviço/socket operacional; encontrado: ${antivirus_detail:-nenhum}" fi ;; clamav) if (( antivirus_count == 1 )) && [[ "$antivirus_detail" == "ClamAV" ]]; then check_result "antivírus" ok "ClamAV exclusivo" else check_result "antivírus" fail "esperado apenas ClamAV; encontrado: ${antivirus_detail:-nenhum}" fi ;; esac # 7. Swap if [[ "$SWAP_ENABLE" == "1" ]]; then if swapon --show --noheadings 2>/dev/null | grep -q .; then check_result "swap" ok "$(swapon --show --noheadings 2>/dev/null | awk 'NR==1 {print $1" "$3}')" else check_result "swap" fail "nenhum swap ativo" fi fi # 8. Licença/binário do cPanel respondendo local cp_version if cp_version="$(timeout 30 /usr/local/cpanel/cpanel -V 2>/dev/null)" && \ cpanel_version_output_valid "$cp_version"; then check_result "cPanel" ok "versão ${cp_version}" else check_result "cPanel" fail "cpanel -V falhou ou retornou formato inválido ('${cp_version:-vazio}')" fi # 9. Licença / whmapi1 + PHP-FPM interno dos aplicativos cPanel local cpanel_fpm_status_json="" phase3_whmapi_ok=0 if whmapi_json_success gethostname >/dev/null; then phase3_whmapi_ok=1 check_result "licença cPanel (whmapi1)" ok "API respondendo" cpanel_fpm_status_json="$(whmapi_json_success servicestatus service=cpanel_php_fpm 2>/dev/null || true)" if jq -e ' .data.service | any(.name == "cpanel_php_fpm" and ((.enabled | tostring) == "1") and ((.monitored | tostring) == "1"))' \ >/dev/null 2>&1 <<<"$cpanel_fpm_status_json" && \ systemctl is-active --quiet cpanel_php_fpm.service 2>/dev/null; then check_result "cpanel_php_fpm" ok "habilitado, monitorado e ativo" elif [[ -f "$FINISH_PENDING_MARKER" ]]; then # Licença ativou, mas o --finish ainda não completou este passo — # é a janela normal do watcher, não uma falha que consome tentativa. check_result "cpanel_php_fpm" pending "licenciado; aguardando o --finish concluir a configuração via Service Manager (timer de 30 min)" else check_result "cpanel_php_fpm" fail "Service Manager ou runtime não confirmou enabled=1/monitored=1/active" fi elif [[ -f "${STATE_DIR}/license.pending" ]]; then check_result "licença cPanel (whmapi1)" pending "aguardando licenciamento; o timer do --finish concluirá API/FPM/backup/AutoSSL/cPHulk" check_result "cpanel_php_fpm" pending "configuração via Service Manager aguarda a licença" else check_result "licença cPanel (whmapi1)" fail "whmapi1 sem resposta — rode /usr/local/cpanel/cpkeyclt e confira a licença do IP" fi # Estado agregado da finalização: garante que um backup/AutoSSL/cPHulk # quebrado no --finish nunca coexista em silêncio com um relatório verde. if [[ -f "${STATE_DIR}/finish.halt" ]]; then check_result "pós-licenciamento (--finish)" fail "finalização SUSPENSA após exceder ${MAX_FINISH_ATTEMPTS} tentativas — investigue ${LOG_DIR}/license-finish.log, remova ${STATE_DIR}/finish.halt e execute ${INSTALL_PATH} --finish" elif [[ -f "$POSTLICENSE_SUCCESS_MARKER" ]]; then check_result "pós-licenciamento (--finish)" ok "todos os passos dependentes de licença/DNS confirmados" elif [[ -f "$FINISH_PENDING_MARKER" || -f "${STATE_DIR}/license.pending" ]]; then check_result "pós-licenciamento (--finish)" pending "aguardando o watcher do --finish (licença/DNS); timer a cada 30 min" else check_result "pós-licenciamento (--finish)" fail "nem concluído nem pendente — estado de finalização inconsistente; rode ${INSTALL_PATH} --finish e verifique ${LOG_DIR}/license-finish.log" fi local hostname_cert_state current_hostname hostname_reconcile_pending=0 hostname_cert_state="$(cat "$HOSTNAME_CERT_STATUS_PATH" 2>/dev/null || echo 'não verificado')" current_hostname="$(cat /proc/sys/kernel/hostname 2>/dev/null || true)" if [[ -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]] && \ is_expected_or_cprapid_hostname "$current_hostname"; then hostname_reconcile_pending=1 fi if hostname_service_certificate_valid; then rm -f "$HOSTNAME_CERT_PENDING_MARKER" if write_hostname_certificate_status \ "válido, confiável e servido em 2087 para ${HOSTNAME_FQDN}"; then check_result "certificado do hostname" ok \ "2087 serve certificado confiável e válido para ${HOSTNAME_FQDN}" else check_result "certificado do hostname" fail \ "certificado servido é válido, mas não foi possível persistir o status" fi elif (( hostname_reconcile_pending == 1 )); then mark_hostname_certificate_pending \ "DNS do hostname desejado ainda pendente; atual=${current_hostname}" || true if is_valid_fqdn "$current_hostname" && \ hostname_service_certificate_valid "$current_hostname"; then hostname_cert_state="provisório válido para ${current_hostname}; aguardando DNS A de ${HOSTNAME_FQDN}" else hostname_cert_state="hostname provisório ${current_hostname}; certificado definitivo aguarda DNS A de ${HOSTNAME_FQDN}" fi write_hostname_certificate_status "$hostname_cert_state" || true check_result "certificado do hostname" pending "$hostname_cert_state" elif (( phase3_whmapi_ok == 1 )) && [[ -n "$AUTOSSL_PROVIDER" ]]; then local hostname_cert_phase3_rc=0 ensure_hostname_service_certificate || hostname_cert_phase3_rc=$? case "$hostname_cert_phase3_rc" in 0) check_result "certificado do hostname" ok \ "2087 serve certificado confiável para ${HOSTNAME_FQDN} após reconciliação" ;; 2) hostname_cert_state="$(cat "$HOSTNAME_CERT_STATUS_PATH" 2>/dev/null || echo 'não verificado')" check_result "certificado do hostname" retry "$hostname_cert_state" ;; *) check_result "certificado do hostname" fail \ "erro interno ao validar/solicitar; veja ${LOG_DIR}/hostname-cert.log" ;; esac else check_result "certificado do hostname" pending "$hostname_cert_state" fi # 10. Confirma kernel, hostname estático, /etc/hostname, canônico em hosts # e API (quando licenciada). Conferir só /proc mascararia persistência/API. if expected_hostname_state_matches; then check_result "hostname" ok "${HOSTNAME_FQDN} consistente em kernel/static/hosts/API" elif (( hostname_reconcile_pending == 1 )); then if is_cprapid_hostname "$current_hostname"; then check_result "hostname" pending \ "${current_hostname} provisório; aguardando ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP} para reconciliação automática" else check_result "hostname" pending \ "estado atual '${current_hostname:-desconhecido}' ainda divergente; aguardando ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP} para reconciliação automática" fi else check_result "hostname" fail "$(hostname_state_details) — rode ${INSTALL_PATH} --finish para reconciliar" fi # 11. Saltos de relógio neste boot — host de virtualização com hora errada # crava a hora no boot (flag host-side); o chrony corrige, mas o tranco # pode derrubar/reiniciar serviços (MariaDB, lfd). Vale cobrar o provedor. local step_max=0 step_val while read -r step_val; do step_val="${step_val%%.*}" step_val="${step_val#-}" if is_integer "$step_val" && (( step_val > step_max )); then step_max=$step_val fi done < <(journalctl -b --no-pager 2>/dev/null | sed -nE 's/.*System clock was stepped by (-?[0-9]+)\..*/\1/p') # O registro no journal deste boot é imutável: marcar FALHA aqui reprovaria # todas as tentativas do mesmo boot e garantiria phase3.halt num servidor # saudável. O problema é do HOST do provedor, não da instalação — reporta # como pendência informativa e notifica sem bloquear. if (( step_max > 60 )); then check_result "relógio do host" pending "salto de ${step_max}s corrigido pelo chrony neste boot — o relógio do HOST do provedor está errado (abra chamado); serviços podem ser reiniciados a cada boot" else check_result "relógio do host" ok "sem saltos relevantes neste boot" fi # --- relatório ----------------------------------------------------------- local verdict="TODAS AS ${pass} VERIFICAÇÕES PASSARAM ✅" (( pending_checks > 0 )) && verdict="${pass} VERIFICAÇÕES PASSARAM; ${pending_checks} PENDÊNCIA(S) NÃO BLOQUEANTE(S)" (( retry_pending_checks > 0 )) && verdict="${pass} VERIFICAÇÕES PASSARAM; PENDÊNCIA(S) AGUARDANDO RECONCILIAÇÃO AUTOMÁTICA" (( fail > 0 )) && verdict="${fail} FALHA(S) EM $(( pass + fail )) VERIFICAÇÕES ⚠" { echo "================================================================================" echo " VERIFICAÇÃO PÓS-REBOOT (FASE 3) — $(date '+%F %T') — ${HOSTNAME_FQDN}" echo "================================================================================" echo "" printf '%s\n' "$report" echo "--------------------------------------------------------------------------------" echo " RESULTADO: ${verdict}" echo " Logs do bootstrap: ${LOG_DIR}/" echo "================================================================================" } > "$PHASE3_REPORT" chmod 0600 "$PHASE3_REPORT" || die "não foi possível proteger ${PHASE3_REPORT}" cat "$PHASE3_REPORT" if (( fail > 0 )); then write_motd if (( phase3_attempt >= MAX_PHASE3_ATTEMPTS )); then touch "${STATE_DIR}/phase3.halt" systemctl disable cpanel-bootstrap-verify.timer >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-verify.timer >/dev/null 2>&1 || true notify "Fase 3 SUSPENSA após ${phase3_attempt} tentativas" "$report Relatório: ${PHASE3_REPORT}" log "Fase 3 suspensa após ${phase3_attempt}/${MAX_PHASE3_ATTEMPTS} tentativas; corrija as falhas e remova ${STATE_DIR}/phase3.halt para repetir." else notify "Verificação pós-reboot falhou (${phase3_attempt}/${MAX_PHASE3_ATTEMPTS})" "$report Nova tentativa automática em ~15 min. Relatório: ${PHASE3_REPORT}" log "Fase 3 falhou; o timer tentará novamente (${phase3_attempt}/${MAX_PHASE3_ATTEMPTS})." fi exit 1 fi if (( retry_pending_checks > 0 )); then # Certificado/DCV assíncrono não consome MAX_PHASE3_ATTEMPTS nem cria # phase3.halt. Sem divergência de hostname, este timer revalida o TLS; # DNS/hostname pendentes são encerrados como pendência não bloqueante e # o --finish dispara um novo relatório após reconciliar. if (( phase3_attempt > 1 )); then printf '%s\n' "$((phase3_attempt - 1))" > "${STATE_DIR}/phase3.attempts" else rm -f "${STATE_DIR}/phase3.attempts" fi rm -f "${STATE_DIR}/phase3.halt" write_motd systemctl enable --now cpanel-bootstrap-verify.timer >/dev/null 2>&1 || true log "Fase 3 aguardando certificado confiável; nova verificação em ~15 min sem consumir tentativas." exit 1 fi touch "${STATE_DIR}/phase3.done" rm -f "${STATE_DIR}/phase3.attempts" "${STATE_DIR}/phase3.halt" write_motd log "Desabilitando o timer de verificação" systemctl disable cpanel-bootstrap-verify.timer >/dev/null 2>&1 || true systemctl disable cpanel-bootstrap-verify.service >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-verify.timer >/dev/null 2>&1 || true notify "Verificação pós-reboot: ${verdict}" "$report Relatório completo: ${PHASE3_REPORT}" log "Fase 3 concluída — relatório em ${PHASE3_REPORT}" } # ============================================================================ # Handler de falha da fase 2 (chamado pelo systemd via ExecStopPost) # ============================================================================ run_on_failure() { trap - ERR set +e local result="${1:-unknown}" mkdir -p "$STATE_DIR" 2>/dev/null load_runtime_config 2>/dev/null # Se outra instância da fase 2 segura o lock, isto não é uma falha real # (ex.: timer disparou durante uma execução manual em andamento) — sai # em silêncio para não gerar notificação falsa. if [[ -f "${STATE_DIR}/phase2.lock" ]]; then if exec 202>>"${STATE_DIR}/phase2.lock" 2>/dev/null; then if ! flock -n 202 2>/dev/null; then exit 0 fi exec 202>&- 2>/dev/null fi fi date '+%F %T' > "${STATE_DIR}/phase2.failed" 2>/dev/null local msg halt_reason if [[ -f "${STATE_DIR}/phase2.halt" ]]; then # A notificação de "SUSPENSA" já foi enviada pelo run_phase2 — aqui # só registramos o estado correto no motd/console, sem prometer # retentativas que o phase2.halt bloqueia. halt_reason="$(read_phase2_halt_reason)" msg="[cpanel-bootstrap] FASE 2 SUSPENSA: ${halt_reason}. Log: ${LOG_DIR}/phase2.log" elif [[ -f "$CPANEL_INSTALL_STARTED_MARKER" && ! -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]]; then # O timer pode voltar a chamar a fase 2, porém o próximo ciclo apenas # reconcilia runtime/log; nunca executa `latest` sobre os próprios # rastros. Se a falha ocorreu dentro do script, ele já notificou ao # gravar CPANEL_INSTALL_FAILED_MARKER; evitamos uma duplicata aqui. msg="[cpanel-bootstrap] FASE 2 PENDENTE: conclusão do instalador ainda não confirmada. O latest NÃO será reexecutado; a próxima execução fará somente a reconciliação final de runtime + log/exit registrado. Log: ${LOG_DIR}/cpanel-install.log" if [[ ! -f "$CPANEL_INSTALL_FAILED_MARKER" ]]; then notify "Fase 2 interrompida durante o instalador cPanel" "O latest não será reexecutado. A próxima execução automática fará somente a reconciliação de runtime + log; se a instalação estiver parcial, será exigido snapshot/SO limpo. Log: ${LOG_DIR}/cpanel-install.log" fi else local attempts="?" if [[ -f "${STATE_DIR}/phase2.attempts" ]]; then attempts="$(cat "${STATE_DIR}/phase2.attempts" 2>/dev/null || echo '?')" fi msg="[cpanel-bootstrap] FASE 2 FALHOU (${result}, tentativa ${attempts}/${MAX_PHASE2_ATTEMPTS}). Log: ${LOG_DIR}/phase2.log — nova tentativa automática em ~15 min; para retomar agora: systemctl start cpanel-bootstrap.service" notify "Fase 2 FALHOU (${result})" "Tentativa ${attempts}/${MAX_PHASE2_ATTEMPTS}. Nova tentativa automática em ~15 min (até o limite). Log: ${LOG_DIR}/phase2.log" fi # Mantém uma única linha de estado atualizada no motd sed -i '/cpanel-bootstrap.*\(FALHOU\|SUSPENSA\|PENDENTE\|INTERROMPIDA\)/d' /etc/motd 2>/dev/null echo "$msg" >> /etc/motd 2>/dev/null echo "$msg" > /dev/console 2>/dev/null exit 0 } # ============================================================================ # Finalização (--finish) — reconcilia licença, DNS/hostname, TLS e WHM API # ============================================================================ # Chamado pelo timer cpanel-bootstrap-license a cada 30 min ou manualmente após # licenciar o IP/publicar o DNS. O nome da unit é mantido por compatibilidade. refresh_phase3_after_finish() { # A fase 3 aceita licença/DNS/certificado como pendências não bloqueantes. # Se ela já fechou nesse estado, o --finish precisa gerar um relatório novo # em vez de deixar phase3.done apontar para o snapshot anterior. if [[ ! -f "${STATE_DIR}/phase3.done" ]]; then # Pode ter havido contenção com o lock do --finish. Garante que uma # unit failed/ativa antiga não impeça a próxima tentativa do timer. if [[ -f "${STATE_DIR}/phase3.halt" ]]; then warn "fase 3 continua suspensa por falhas reais; consulte ${PHASE3_REPORT}" return 1 fi systemctl reset-failed cpanel-bootstrap-verify.service >/dev/null 2>&1 || true # A fase 3 valida o estado PÓS-REBOOT (/tmp, sysctl, boot limpo). No # mesmo boot da fase 2, um enable --now faria o OnBootSec (já vencido) # disparar a verificação imediatamente e phase3.done nasceria antes do # reboot — anulando a fase 3. Nesse caso, apenas deixa o timer # habilitado para o próximo boot. local current_boot phase2_boot current_boot="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)" phase2_boot="$(cat "${STATE_DIR}/phase2.boot_id" 2>/dev/null || true)" if [[ -n "$current_boot" && "$current_boot" == "$phase2_boot" ]]; then if ! systemctl enable cpanel-bootstrap-verify.timer >/dev/null 2>&1; then warn "não foi possível manter cpanel-bootstrap-verify.timer habilitado" return 1 fi log " ⓘ Fase 3 segue agendada para depois do reboot (servidor ainda no boot da fase 2)" return 0 fi if ! systemctl enable --now cpanel-bootstrap-verify.timer >/dev/null 2>&1; then warn "não foi possível manter cpanel-bootstrap-verify.timer ativo" return 1 fi return 0 fi # Mesma guarda de boot do primeiro ramo: um phase3.done gravado por versão # antiga NESTE boot (upgrade sem reboot) não pode ser destruído aqui — o # gate de boot da run_phase3 sairia sem regravar nada, o relatório sumiria # e um --finish saudável morreria com alerta falso de fase 3. local refresh_current_boot refresh_phase2_boot refresh_current_boot="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)" refresh_phase2_boot="$(cat "${STATE_DIR}/phase2.boot_id" 2>/dev/null || true)" if [[ -n "$refresh_current_boot" && "$refresh_current_boot" == "$refresh_phase2_boot" ]]; then # O done sai (a Condition do timer bloquearia a revalidação pós-boot), # mas o relatório fica como referência até a fase 3 real reescrevê-lo. rm -f "${STATE_DIR}/phase3.done" "${STATE_DIR}/phase3.attempts" log " ⓘ Relatório da fase 3 preservado; a revalidação completa roda após o próximo reboot" systemctl enable cpanel-bootstrap-verify.timer >/dev/null 2>&1 || \ warn "não foi possível manter cpanel-bootstrap-verify.timer habilitado" return 0 fi log "Atualizando a verificação pós-reboot após concluir as pendências externas" systemctl stop cpanel-bootstrap-verify.timer >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-verify.service >/dev/null 2>&1 || true systemctl reset-failed cpanel-bootstrap-verify.service >/dev/null 2>&1 || true rm -f "${STATE_DIR}/phase3.done" "${STATE_DIR}/phase3.attempts" \ "$HOSTNAME_CERT_PENDING_MARKER" "$PHASE3_REPORT" if systemctl start cpanel-bootstrap-verify.service >/dev/null 2>&1 && \ [[ -f "${STATE_DIR}/phase3.done" ]]; then log " ✓ Relatório da fase 3 atualizado após a reconciliação" return 0 fi if [[ -f "$HOSTNAME_CERT_PENDING_MARKER" && ! -f "${STATE_DIR}/phase3.halt" ]] && \ ! grep -qF ' [FALHA]' "$PHASE3_REPORT" 2>/dev/null; then log " ⓘ Fase 3 atualizada; certificado ainda aguarda retry seguro" systemctl reset-failed cpanel-bootstrap-verify.service >/dev/null 2>&1 || true if ! systemctl enable --now cpanel-bootstrap-verify.timer >/dev/null 2>&1; then warn "não foi possível rearmar cpanel-bootstrap-verify.timer" return 1 fi return 0 fi warn "A nova verificação pós-reconciliação encontrou falha ou não concluiu; o timer tentará novamente" systemctl enable --now cpanel-bootstrap-verify.timer >/dev/null 2>&1 || \ warn "não foi possível rearmar cpanel-bootstrap-verify.timer; execute ${INSTALL_PATH} --phase3 manualmente" return 1 } run_finish() { [[ $EUID -eq 0 ]] || die "este script precisa ser executado como root" umask 022 export HOME="${HOME:-/root}" install -d -m 0700 "$LOG_DIR" "$STATE_DIR" "$STEP_DIR" load_runtime_config validate_config export PATH="/usr/local/cpanel/bin:/usr/local/cpanel/scripts:$PATH" # Upgrade no meio do fluxo: um --finish manual com script mais novo # também moderniza os units (timeouts, Conditions de finish.halt). refresh_bootstrap_units_if_outdated [[ -f "${STATE_DIR}/phase2.done" ]] || \ die "fase 2 ainda não concluída — o --finish só se aplica após o bootstrap" [[ ! -f "${STATE_DIR}/finish.halt" ]] || \ die "finalização suspensa após falhas repetidas: investigue ${LOG_DIR}/license-finish.log, corrija, remova ${STATE_DIR}/finish.halt e execute ${INSTALL_PATH} --finish" # O halt desabilita o timer do watcher; quem removeu finish.halt e rodou # --finish manualmente recupera o ciclo automático AQUI, mesmo que esta # execução volte a falhar (ao contrário do phase2.halt, cujo timer segue # enabled, o license.timer precisa ser rearmado explicitamente). if [[ -f "$FINISH_PENDING_MARKER" ]] && \ ! systemctl is-enabled --quiet cpanel-bootstrap-license.timer 2>/dev/null; then systemctl enable --now cpanel-bootstrap-license.timer >/dev/null 2>&1 || \ warn "não foi possível reabilitar o timer do watcher --finish" fi # Evita corrida entre execução manual e o timer de licenciamento. exec 204>"${STATE_DIR}/finish.lock" if ! flock -n 204; then log "Outra execução do --finish já está em andamento — saindo sem alterar estado." exit 0 fi # phase2.done é publicado nos instantes finais da fase 2. Se o calendário # do watcher coincidir com essa janela, aguarda o processo encerrar antes # de ler markers ou regenerar o resumo. exec 203>>"${STATE_DIR}/phase2.lock" if ! flock -w 300 203; then die "a fase 2 ainda está encerrando; aguarde e repita o --finish" fi local pending=0 st validation_note hostname_reconcile_rc=0 local -a finish_steps=(cpanel_hostname api_tweaks fpm_default cpanel_php_fpm cphulk autossl backup) [[ -f "${STATE_DIR}/license.pending" ]] && pending=1 [[ -f "$POSTLICENSE_SUCCESS_MARKER" ]] || pending=1 if expected_hostname_state_matches; then # Recupera um encerramento ocorrido entre a confirmação do hostname e # a remoção do marker de pendência. rm -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" else rm -f "${STEP_DIR}/cpanel_hostname.done" pending=1 fi for st in "${finish_steps[@]}"; do [[ -f "${STEP_DIR}/${st}.done" ]] || pending=1 done if [[ -n "$AUTOSSL_PROVIDER" ]] && ! hostname_service_certificate_valid; then # Migra instalações concluídas por versões antigas que aceitavam o # exit 0 do checkallsslcerts sem conferir o TLS servido. rm -f "${STEP_DIR}/autossl.done" pending=1 fi if (( pending == 0 )); then rm -f "$FINISH_PENDING_MARKER" systemctl disable cpanel-bootstrap-license.timer >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-license.timer >/dev/null 2>&1 || true log "Nada pendente de finalização — tudo já concluído." exit 0 fi # A fase 3 lê exatamente os serviços/arquivos que set_hostname e AutoSSL # alteram. Serializa a janela de mutação para não produzir uma tentativa # e uma notificação falsas enquanto o --finish reinicia esses serviços. exec 206>"${STATE_DIR}/phase3.lock" if ! flock -w 300 206; then die "a fase 3 continua em execução; aguarde e repita o --finish" fi # Execuções do --finish que morrem em step_fail eram invisíveis: a unit # failed é excluída do check da fase 3 e o timer repetia a cada 30 min # para sempre. O contador vive ANTES do primeiro passo real (o reparo de # hostname também pode falhar de forma persistente) e é zerado em toda # saída legítima — conclusão, aguardo de licença ou de DNS. Ao exceder o # teto, grava finish.halt (Condition nos units) e suspende o watcher. local finish_attempts=0 if [[ -f "${STATE_DIR}/finish.attempts" ]]; then finish_attempts="$(cat "${STATE_DIR}/finish.attempts" 2>/dev/null || echo 0)" is_integer "$finish_attempts" || finish_attempts=0 fi finish_attempts=$((finish_attempts + 1)) echo "$finish_attempts" > "${STATE_DIR}/finish.attempts" if (( finish_attempts > MAX_FINISH_ATTEMPTS )); then local finish_limit_reason finish_limit_reason="o --finish falhou ${MAX_FINISH_ATTEMPTS} vezes seguidas sem concluir a finalização" touch "${STATE_DIR}/finish.halt" rm -f "${STATE_DIR}/finish.attempts" systemctl disable cpanel-bootstrap-license.timer >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-license.timer >/dev/null 2>&1 || true notify "Finalização (--finish) SUSPENSA após ${MAX_FINISH_ATTEMPTS} tentativas" "${finish_limit_reason}. Investigue ${LOG_DIR}/license-finish.log; após corrigir, remova ${STATE_DIR}/finish.halt e execute ${INSTALL_PATH} --finish" die "${finish_limit_reason} — suspensa via ${STATE_DIR}/finish.halt" fi if (( finish_attempts >= 2 )); then warn "a execução anterior do --finish não concluiu (tentativa ${finish_attempts}/${MAX_FINISH_ATTEMPTS})" if (( finish_attempts == 2 || finish_attempts % 4 == 0 )); then notify "Finalização (--finish) falhando (${finish_attempts}/${MAX_FINISH_ATTEMPTS})" "O pós-licenciamento não conclui. Log: ${LOG_DIR}/license-finish.log — nova tentativa automática a cada 30 min até o limite." fi fi # Qualquer divergência invalida o marker agregado até que todos os passos, # o resumo e a persistência final sejam confirmados novamente. rm -f "$POSTLICENSE_SUCCESS_MARKER" # Este reparo usa o utilitário oficial do cPanel e não depende da licença. # Executa antes do gate da API para recuperar inclusive servidores antigos # que receberam hostname cprapid durante a instalação. if step_begin cpanel_hostname; then log "Reconciliando hostname do cPanel antes das tarefas licenciadas" reconcile_cpanel_hostname || hostname_reconcile_rc=$? case "$hostname_reconcile_rc" in 0) step_end ;; 2) step_warn "DNS/hostname ainda pendente; mantendo o hostname provisório e aguardando o registro A" ;; *) step_fail "hostname do cPanel não pôde ser reconciliado; veja ${LOG_DIR}/hostname-reconcile.log" ;; esac fi if ! whmapi_json_success gethostname >/dev/null; then /usr/local/cpanel/cpkeyclt >/dev/null 2>&1 || true if ! whmapi_json_success gethostname >/dev/null; then touch "${STATE_DIR}/license.pending" mark_finish_pending "licença cPanel" || \ die "não foi possível persistir a pendência de licença" systemctl enable --now cpanel-bootstrap-license.timer >/dev/null 2>&1 || \ warn "não foi possível armar o timer de licenciamento; repita ${INSTALL_PATH} --finish manualmente" # Aguardar licença é saída legítima, não falha: zera o contador. rm -f "${STATE_DIR}/finish.attempts" log "Licença ainda inválida/ausente — nada a fazer (o timer verifica de novo em ~30 min)" exit 0 fi fi WHMAPI_OK=1 rm -f "${STATE_DIR}/license.pending" log "===================================================================" log " Licença ATIVA — completando passos pendentes (script v${VERSION})" log "===================================================================" if step_begin api_tweaks; then log "Reconciliando Tweak Settings via API" apply_core_tweaks || step_fail "não foi possível confirmar os Tweak Settings essenciais" apply_security_tweaks || step_fail "não foi possível reconciliar os Tweak Settings de segurança" normalize_tmp_size_mb apply_tweak_if_exists securetmp_file_size "$TMP_SIZE_MB" step_end fi if step_begin fpm_default; then configure_fpm_default || step_fail "não foi possível confirmar PHP-FPM como default para contas novas" step_end fi if step_begin cpanel_php_fpm; then configure_cpanel_php_fpm_service || \ step_fail "não foi possível habilitar/monitorar cpanel_php_fpm" step_end fi if step_begin cphulk; then configure_cphulk || step_fail "não foi possível habilitar/confirmar cPHulk e whitelist" step_end fi if step_begin autossl; then configure_autossl || step_fail "não foi possível configurar/confirmar o provider AutoSSL" step_end fi if step_begin backup; then configure_backups || step_fail "backup local/remoto não pôde ser configurado e confirmado" step_end fi if [[ -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then # A licença e os demais recursos já podem estar prontos, mas o hostname # oficial só deve ser aplicado quando o DNS apontar para este servidor. # Mantém o marker agregado ausente e o watcher ativo sem tratar a # dependência externa como falha da instalação. generate_summary chmod 0600 "$SUMMARY_FILE" || die "não foi possível proteger ${SUMMARY_FILE}" rm -f "$POSTLICENSE_SUCCESS_MARKER" # Espera legítima por dependência externa, não falha: zera o contador # de execuções licenciadas para não escalar/suspender à toa. rm -f "${STATE_DIR}/finish.attempts" mark_finish_pending "DNS/hostname" || \ die "não foi possível persistir a pendência de DNS/hostname" systemctl enable --now cpanel-bootstrap-license.timer >/dev/null 2>&1 || \ warn "não foi possível manter o watcher de DNS/hostname ativo; execute ${INSTALL_PATH} --finish após apontar o registro A" log "DNS de ${HOSTNAME_FQDN} ainda pendente — nova tentativa automática em até 30 min" log "Após publicar o A para ${SERVER_PUBLIC_IP}, você também pode executar: ${INSTALL_PATH} --finish" exit 0 fi for st in "${finish_steps[@]}"; do [[ -f "${STEP_DIR}/${st}.done" ]] || \ die "pós-licenciamento incompleto: passo '${st}' continua pendente" done postlicense_steps_complete || die "pós-licenciamento incompleto" # Regenera/protege o resumo e grava um marker próprio antes de remover a # condição do timer. Se algo falhar, license.pending permanece e a próxima # execução retoma inclusive quando todos os markers de passo já existiam. generate_summary chmod 0600 "$SUMMARY_FILE" || die "não foi possível proteger ${SUMMARY_FILE}" mark_postlicense_complete || \ die "não foi possível persistir a conclusão do pós-licenciamento" rm -f "${STATE_DIR}/license.pending" rm -f "${STATE_DIR}/finish.attempts" rm -f "$FINISH_PENDING_MARKER" systemctl disable cpanel-bootstrap-license.timer >/dev/null 2>&1 || true systemctl stop cpanel-bootstrap-license.timer >/dev/null 2>&1 || true # refresh_phase3_after_finish pode iniciar a própria unit da fase 3; ela # precisa adquirir o lock que protegemos durante as mutações acima. flock -u 206 || true exec 206>&- refresh_phase3_after_finish || { warn "Pós-licenciamento concluído, mas a fase 3 ainda encontrou falhas; consulte ${PHASE3_REPORT}" notify "Pós-licenciamento concluído; fase 3 pendente" "As tarefas dependentes da licença foram aplicadas, porém a validação pós-reboot precisa de nova tentativa. Relatório: ${PHASE3_REPORT}" die "tarefas pós-licença concluídas, mas a validação da fase 3 falhou; consulte ${PHASE3_REPORT}" } if [[ -f "${STATE_DIR}/phase3.done" ]]; then validation_note="fase 3 validada" else validation_note="fase 3 ainda pendente" fi log "Finalização automática concluída — ${validation_note}." notify "Finalização automática concluída ✅" "Hostname, API/FPM, backup, provider AutoSSL e cPHulk confirmados em ${HOSTNAME_FQDN}. O estado do certificado do hostname está no resumo: ${SUMMARY_FILE}" } # ============================================================================ # Status do provisionamento # ============================================================================ run_status() { trap - ERR set +e # Os marcadores em STATE_DIR são 0700: sem root, [[ -f ]] retornaria # falso e o status mentiria "fase 1 pendente" num servidor provisionado. if [[ $EUID -ne 0 && ! -r "$STATE_DIR" ]]; then echo "cpanel-bootstrap v${VERSION}: execute como root para ver o status do provisionamento." exit 1 fi load_runtime_config printf 'cpanel-bootstrap v%s — status do provisionamento\n' "$VERSION" printf -- '------------------------------------------------\n' if [[ -f "${STATE_DIR}/phase1.done" ]]; then echo "Fase 1 : concluída" else echo "Fase 1 : pendente (execute: bash ${INSTALL_PATH})" fi if [[ -f "${STATE_DIR}/phase2.done" ]]; then echo "Fase 2 : concluída" elif [[ -f "${STATE_DIR}/phase2.halt" ]]; then echo "Fase 2 : SUSPENSA — $(read_phase2_halt_reason)" echo " → investigue ${LOG_DIR}/phase2.log" if [[ -f "$CPANEL_INSTALL_FAILED_MARKER" ]]; then echo " → não remova markers nem execute o latest; restaure o snapshot/SO limpo" else echo " → após corrigir, remova ${STATE_DIR}/phase2.halt e retome o serviço" fi elif [[ -f "${STATE_DIR}/phase2.failed" ]]; then if [[ -f "$CPANEL_INSTALL_STARTED_MARKER" && ! -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]]; then echo "Fase 2 : aguardando reconciliação final automática do instalador" echo " → o latest não será reexecutado; somente runtime + log/exit registrado serão verificados" else echo "Fase 2 : FALHOU em $(cat "${STATE_DIR}/phase2.failed" 2>/dev/null) — retry automático a cada ~15 min" fi elif [[ -f "${STATE_DIR}/phase1.done" ]]; then echo "Fase 2 : pendente/em execução (log: tail -f ${LOG_DIR}/phase2.log)" else echo "Fase 2 : aguardando a fase 1" fi if [[ -f "${STATE_DIR}/phase2.attempts" ]]; then echo "Tentativas da fase 2: $(cat "${STATE_DIR}/phase2.attempts" 2>/dev/null)/${MAX_PHASE2_ATTEMPTS}" fi if [[ -f "$CPANEL_INSTALL_SUCCESS_MARKER" ]]; then echo "Instalador cPanel: concluído e validado (não será reexecutado)" elif [[ -f "$CPANEL_INSTALL_FAILED_MARKER" ]]; then if [[ -f "${STATE_DIR}/phase2.halt" ]]; then echo "Instalador cPanel: PARCIAL/INCOMPLETO — snapshot ou SO limpo obrigatório" else echo "Instalador cPanel: conclusão ainda não confirmada — reconciliação final pendente" fi elif [[ -f "$CPANEL_INSTALL_STARTED_MARKER" ]]; then echo "Instalador cPanel: iniciado; conclusão ainda não confirmada" else echo "Instalador cPanel: ainda não executado" fi if [[ -f "${STATE_DIR}/gap-firewall.active" ]]; then local gap_admin_ips gap_admin_ips="$(echo "${ADMIN_SRC_IP:-} ${ADMIN_ALLOW_IPS:-}" | xargs 2>/dev/null || true)" if [[ -n "$gap_admin_ips" ]]; then echo "Firewall transitório: ativo — WHM/2087 permitido somente para: ${gap_admin_ips}" else echo "Firewall transitório: ativo — WHM/2087 bloqueado externamente até o CSF assumir" fi fi if [[ -f "${STATE_DIR}/current_step" ]]; then echo "Passo atual: $(cat "${STATE_DIR}/current_step" 2>/dev/null)" fi if [[ -d "$STEP_DIR" ]] && ls "$STEP_DIR"/*.done >/dev/null 2>&1; then echo "Passos concluídos:" for f in "$STEP_DIR"/*.done; do basename "$f" .done | sed 's/^/ ✓ /' done fi if [[ -d "$STEP_DIR" ]] && ls "$STEP_DIR"/*.warning >/dev/null 2>&1; then echo "Passos com aviso/pendência:" for f in "$STEP_DIR"/*.warning; do printf ' ! %s: %s\n' "$(basename "$f" .warning)" "$(cat "$f" 2>/dev/null)" done fi if [[ -d "$STEP_DIR" ]] && ls "$STEP_DIR"/*.failed >/dev/null 2>&1; then echo "Passos que falharam:" for f in "$STEP_DIR"/*.failed; do printf ' ✗ %s: %s\n' "$(basename "$f" .failed)" "$(cat "$f" 2>/dev/null)" done fi if [[ -f "${STATE_DIR}/phase3.done" ]]; then echo "Fase 3 : concluída — relatório em ${PHASE3_REPORT}" elif [[ -f "${STATE_DIR}/phase3.halt" ]]; then echo "Fase 3 : SUSPENSA após ${MAX_PHASE3_ATTEMPTS} tentativas" echo " → corrija ${PHASE3_REPORT}, remova ${STATE_DIR}/phase3.halt" echo " → e rode: systemctl start cpanel-bootstrap-verify.service" elif [[ -f "${STATE_DIR}/phase2.done" ]]; then if [[ -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then echo "Fase 3 : aguardando DNS/hostname/certificado (retry sem consumir tentativas)" elif [[ -f "$HOSTNAME_CERT_PENDING_MARKER" ]]; then echo "Fase 3 : aguardando certificado confiável em 2087 (retry sem consumir tentativas)" elif [[ -f "${STATE_DIR}/phase3.attempts" ]]; then echo "Fase 3 : falhou/penderá nova tentativa ($(cat "${STATE_DIR}/phase3.attempts" 2>/dev/null)/${MAX_PHASE3_ATTEMPTS})" else echo "Fase 3 : pendente — roda ~3 min após o próximo reboot" fi fi if [[ -f "${STATE_DIR}/finish.halt" ]]; then echo "Finalização: SUSPENSA — o --finish excedeu ${MAX_FINISH_ATTEMPTS} tentativas" echo " → investigue ${LOG_DIR}/license-finish.log, corrija, remova ${STATE_DIR}/finish.halt" echo " e execute ${INSTALL_PATH} --finish (rearma o watcher)" elif [[ -f "${STATE_DIR}/license.pending" ]]; then echo "Licença: PENDENTE — API/FPM/backup/AutoSSL/cPHulk completam automaticamente após" echo " licenciar o IP (timer 30 min), ou rode: ${INSTALL_PATH} --finish" elif [[ -f "$POSTLICENSE_SUCCESS_MARKER" ]]; then echo "Licença: ATIVA — pós-licenciamento concluído" elif [[ -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then echo "Licença: ATIVA/API disponível — conclusão aguarda o DNS/hostname" elif [[ -f "${STATE_DIR}/phase2.done" ]]; then echo "Licença/pós-configuração: estado final pendente — rode: ${INSTALL_PATH} --finish" fi if [[ -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then echo "Hostname: PENDENTE — atual '$(cat /proc/sys/kernel/hostname 2>/dev/null || echo n/d)'" echo " aguardando ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP}; retry automático em até 30 min" fi if [[ -f "$HOSTNAME_CERT_STATUS_PATH" ]]; then echo "Certificado do hostname: $(cat "$HOSTNAME_CERT_STATUS_PATH" 2>/dev/null)" fi echo "" echo "systemd:" printf ' cpanel-bootstrap.service : %s / %s\n' \ "$(systemctl is-active cpanel-bootstrap.service 2>/dev/null)" \ "$(systemctl is-enabled cpanel-bootstrap.service 2>/dev/null)" printf ' cpanel-bootstrap.timer : %s / %s\n' \ "$(systemctl is-active cpanel-bootstrap.timer 2>/dev/null)" \ "$(systemctl is-enabled cpanel-bootstrap.timer 2>/dev/null)" if [[ -f "$VERIFY_TIMER_PATH" ]]; then printf ' verify.timer (fase 3) : %s / %s\n' \ "$(systemctl is-active cpanel-bootstrap-verify.timer 2>/dev/null)" \ "$(systemctl is-enabled cpanel-bootstrap-verify.timer 2>/dev/null)" fi if [[ -f "$LICENSE_TIMER_PATH" ]]; then printf ' finish.timer (licença/DNS): %s / %s\n' \ "$(systemctl is-active cpanel-bootstrap-license.timer 2>/dev/null)" \ "$(systemctl is-enabled cpanel-bootstrap-license.timer 2>/dev/null)" fi echo "" if is_cpanel_installed; then echo "cPanel : instalado ($(timeout 10 /usr/local/cpanel/cpanel -V 2>/dev/null || echo 'versão n/d'))" else echo "cPanel : não instalado" fi echo "SSH : listeners $(ss -tln 2>/dev/null | awk '/LISTEN/ {print $4}' | grep -E ':(22|'"${SSH_PORT}"')$' | sort -u | tr '\n' ' ')" [[ -f "$SUMMARY_FILE" ]] && echo "Resumo : ${SUMMARY_FILE}" [[ -f "$PHASE3_REPORT" ]] && echo "Fase 3 : ${PHASE3_REPORT}" exit 0 } # ============================================================================ # Geração do resumo final # ============================================================================ generate_summary() { log "Gerando resumo final em $SUMMARY_FILE" # O --finish pode pular api_tweaks porque o passo já possui marker. Nesse # caso TMP_SIZE_MB ainda não existe nesta nova shell; normalize sempre # antes de expandi-lo no resumo. normalize_tmp_size_mb local cpanel_version csf_version kernel uptime_str public_ip local mem_total disk_root selinux_state ssh_listen timezone_now local tmp_current_size # Todos os comandos externos protegidos por `timeout` para não travar o # resumo caso algum binário (cpanel, csf, whmapi1) fique pendurado. cpanel_version="$(timeout 10 /usr/local/cpanel/cpanel -V 2>/dev/null || echo 'não detectado')" csf_version="$(timeout 10 csf -v 2>/dev/null | head -1 || echo 'não detectado')" kernel="$(uname -r)" uptime_str="$(uptime -p 2>/dev/null || echo 'n/d')" public_ip="${SERVER_PUBLIC_IP:-}" # (sem `timeout`: não executa funções de shell; os curls internos já têm --max-time) [[ -n "$public_ip" ]] || public_ip="$(detect_public_ipv4 2>/dev/null || echo 'não detectado')" mem_total="$(free -h | awk '/^Mem:/ {print $2}')" disk_root="$(df -h / | awk 'NR==2 {print $2" total, "$4" livre"}')" selinux_state="$(getenforce 2>/dev/null || echo 'indisponível')" ssh_listen="$(ss -tlnp 2>/dev/null | awk '/sshd/ {print $4}' | sort -u | tr '\n' ' ')" [[ -z "${ssh_listen// }" ]] && ssh_listen="não detectado" timezone_now="$(timedatectl show --property=Timezone --value 2>/dev/null || echo 'n/d')" local antivirus_status pureftpd_status php_count php_list fixquotas_status imav_summary_mode local fpm_summary_max_children csf_alert_status epel_status ssh_22_note case "$ANTIVIRUS" in imunifyav) imav_summary_mode="$(imunifyav_runtime_mode 2>/dev/null || true)" if [[ -n "$imav_summary_mode" ]]; then antivirus_status="ImunifyAV operacional e exclusivo (${imav_summary_mode})" else antivirus_status="ImunifyAV instalado; verificar RPC/serviço/socket" fi ;; clamav) antivirus_status="$(rpm -q cpanel-clamav >/dev/null 2>&1 && echo 'ClamAV instalado (exclusivo)' || echo 'ClamAV configurado; verificar pacote')" ;; none) antivirus_status="nenhum (política explícita)" ;; esac pureftpd_status="$(timeout 5 systemctl is-active pure-ftpd 2>/dev/null || echo 'inativo')" case "$(cat "${STATE_DIR}/fixquotas.status" 2>/dev/null || echo 'unknown')" in ok) fixquotas_status="executado com sucesso" ;; reboot) fixquotas_status="quotas atualizadas; ativas após reboot (XFS)" ;; erro:*) fixquotas_status="executado com alerta — ver ${LOG_DIR}/fixquotas.log" ;; missing) fixquotas_status="script não encontrado — verificar manualmente" ;; *) fixquotas_status="não detectado" ;; esac php_list="$(find /opt/cpanel -maxdepth 1 -type d -name 'ea-php*' 2>/dev/null \ | awk -F/ '{print $NF}' | sort -u | tr '\n' ' ' || true)" php_count="$(echo "$php_list" | wc -w)" [[ -z "${php_list// }" ]] && php_list="(nenhuma)" fpm_summary_max_children="$(calculate_php_fpm_max_children)" csf_alert_status="$([[ "$CSF_CRITICAL_EMAIL_ALERTS" == "1" ]] && echo 'ATIVADOS' || echo 'DESATIVADOS')" ssh_22_note="$([[ "$KEEP_SSH_PORT_22_OPEN" == "1" && "$SSH_PORT" != "22" ]] && echo ' + porta 22 mantida para validação' || echo '')" if rpm -q epel-release >/dev/null 2>&1 && \ dnf -q repolist --enabled 2>/dev/null | awk '{print $1}' | grep -qx epel; then epel_status="instalado, habilitado e validado" else epel_status="não instalado/verificar manualmente" fi # Status dos novos recursos da v2 local mysql_tuning_status backup_status backup_json modsec_status dkim_status local fpm_default_status security_tweaks_summary security_config_status local password_strength_status compiler_status strength_json cphulk_summary_status cphulk_json local hostname_cert_status autossl_new_domains_status autossl_summary_status local autossl_providers_json panel_ini_autossl_value expected_panel_autossl local swap_status chrony_status ftp_tls_label admin_ips_status notify_status mysql_tuning_status="$([[ -f /etc/my.cnf.d/zz-cpanel-bootstrap.cnf ]] && echo 'aplicado (/etc/my.cnf.d/zz-cpanel-bootstrap.cnf)' || echo 'não aplicado')" backup_json="$(whmapi_json_success backup_config_get 2>/dev/null || true)" if jq -e '.data.backup_config | with_entries(.key |= ascii_downcase) | (.backupenable | tostring) == "1"' \ >/dev/null 2>&1 <<<"$backup_json"; then backup_status="ativado — diário (ret. ${BACKUP_DAILY_RETENTION}d) + semanal (ret. ${BACKUP_WEEKLY_RETENTION}sem) em /backup" elif [[ "$BACKUP_ENABLE" == "0" ]]; then backup_status="desativado por configuração" else backup_status="verificar em WHM → Backup Configuration" fi modsec_status="$(rpm -q ea-modsec2-rules-owasp-crs >/dev/null 2>&1 && echo 'ModSecurity 2 + OWASP CRS ativos' || echo 'não instalado')" fpm_default_status="$([[ -f "${STEP_DIR}/fpm_default.done" ]] && echo 'confirmado via WHM API' || echo 'pendente de licença/API')" if [[ "$SECURITY_TWEAKS" == "0" ]]; then dkim_status="automação de DKIM/SPF desativada por SECURITY_TWEAKS=0" security_tweaks_summary=" ⓘ Tweaks de segurança de contas: desativados por SECURITY_TWEAKS=0" else if grep -qx 'create_account_dkim=1' "$CPANEL_CONFIG" 2>/dev/null && \ grep -qx 'create_account_spf=1' "$CPANEL_CONFIG" 2>/dev/null && \ grep -qx 'jaildefaultshell=1' "$CPANEL_CONFIG" 2>/dev/null; then security_config_status="DKIM/SPF automáticos + jailshell confirmados" dkim_status="DKIM+SPF automáticos para contas novas" else security_config_status="VERIFICAR em Tweak Settings" dkim_status="verificar em Tweak Settings" fi strength_json="$(whmapi_json_success getminimumpasswordstrengths name=default 2>/dev/null || true)" if jq -e --arg wanted "$MIN_PW_STRENGTH" \ '(.data.default | tostring) == $wanted' >/dev/null 2>&1 <<<"$strength_json"; then password_strength_status="${MIN_PW_STRENGTH} (confirmado via API dedicada)" elif [[ -f "${STATE_DIR}/license.pending" ]]; then password_strength_status="${MIN_PW_STRENGTH} pendente de licença/API" else password_strength_status="VERIFICAR (esperado ${MIN_PW_STRENGTH})" fi if [[ -e /usr/bin/gcc ]] && \ [[ "$(stat -Lc '%u:%G:%a' /usr/bin/gcc 2>/dev/null || true)" == "0:compiler:750" ]]; then compiler_status="confirmado root:compiler 0750" elif [[ ! -e /usr/bin/gcc && -f "${STATE_DIR}/compilers.restricted" ]]; then compiler_status="política aplicada; gcc não está instalado" else compiler_status="VERIFICAR em WHM → Compiler Access" fi printf -v security_tweaks_summary '%s\n%s\n%s\n%s' \ ' ⓘ Tweaks de segurança de contas (SECURITY_TWEAKS=1):' \ " - DKIM/SPF/jailshell: ${security_config_status}" \ " - força mínima de senha: ${password_strength_status}" \ " - compiladores: ${compiler_status}" fi cphulk_json="$(whmapi_json_success cphulk_status 2>/dev/null || true)" if jq -e '(.data.is_enabled | tostring) == "1"' >/dev/null 2>&1 <<<"$cphulk_json"; then cphulk_summary_status="ativo e confirmado via API" elif [[ -f "${STATE_DIR}/license.pending" ]]; then cphulk_summary_status="pendente de licenciamento/API" else cphulk_summary_status="VERIFICAR em WHM → cPHulk" fi hostname_cert_status="$(cat "$HOSTNAME_CERT_STATUS_PATH" 2>/dev/null || echo 'não verificado')" autossl_providers_json="$(whmapi_json_success get_autossl_providers 2>/dev/null || true)" if [[ -z "$AUTOSSL_PROVIDER" ]]; then autossl_summary_status="provider atual do cPanel mantido" elif jq -e --arg provider "$AUTOSSL_PROVIDER" ' .data.payload | any(.module_name == $provider and ((.enabled | tostring) == "1"))' \ >/dev/null 2>&1 <<<"$autossl_providers_json"; then autossl_summary_status="provider ${AUTOSSL_PROVIDER} confirmado via API" elif [[ -f "${STATE_DIR}/license.pending" ]]; then autossl_summary_status="provider ${AUTOSSL_PROVIDER} pendente de licença/API" else autossl_summary_status="VERIFICAR provider ${AUTOSSL_PROVIDER} no WHM" fi panel_ini_autossl_value="$(awk ' /^[[:space:]]*\[global\][[:space:]]*$/ { in_global=1; next } /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { in_global=0; next } in_global && /^[[:space:]]*autossl\.exclude_new_domains[[:space:]]*=/ { line=$0 sub(/^[^=]*=/, "", line) gsub(/^[[:space:]]*|[[:space:]]*$/, "", line) print line exit } ' /var/cpanel/panel.ini 2>/dev/null || true)" expected_panel_autossl="$([[ "$AUTOSSL_INCLUDE_NEW_DOMAINS" == "1" ]] && echo false || echo true)" if [[ "$panel_ini_autossl_value" == "$expected_panel_autossl" ]]; then autossl_new_domains_status="$([[ "$AUTOSSL_INCLUDE_NEW_DOMAINS" == "1" ]] && echo 'novos domínios incluídos automaticamente' || echo 'novos domínios excluídos') (panel.ini confirmado)" elif [[ -f "${STATE_DIR}/license.pending" ]]; then autossl_new_domains_status="inclusão de novos domínios pendente de licença/API" else autossl_new_domains_status="VERIFICAR autossl.exclude_new_domains no panel.ini" fi swap_status="$(swapon --show --noheadings 2>/dev/null | awk 'NR==1 {print $1" ("$3")"}')" [[ -z "$swap_status" ]] && swap_status="sem swap" chrony_status="$(timeout 5 systemctl is-active chronyd 2>/dev/null || echo 'inativo')" case "$FTP_TLS_LEVEL" in 0) ftp_tls_label="sem TLS (texto claro!)" ;; 1) ftp_tls_label="TLS opcional" ;; 2) ftp_tls_label="TLS obrigatório no controle" ;; 3) ftp_tls_label="TLS obrigatório (controle+dados)" ;; esac admin_ips_status="$(echo "${ADMIN_SRC_IP:-} ${ADMIN_ALLOW_IPS:-}" | xargs 2>/dev/null || true)" [[ -z "$admin_ips_status" ]] && admin_ips_status="(nenhum — configure ADMIN_ALLOW_IPS)" if [[ -n "$NOTIFY_WEBHOOK_URL" || -n "$NOTIFY_TELEGRAM_BOT_TOKEN" ]]; then notify_status="ativas (webhook/telegram)" elif [[ "$ADMIN_EMAIL" != "root@localhost" ]]; then notify_status="por e-mail (${ADMIN_EMAIL})" else notify_status="desativadas" fi # Licença/whmapi1 + hostname real + bloco de pendências quando a API # estava indisponível durante a fase 2 local whmapi_status pendencias_block="" hostname_real hostname_display whm_access_hostname if whmapi_json_success gethostname >/dev/null 2>&1; then whmapi_status="OK (API respondendo)" else whmapi_status="AGUARDANDO LICENCIAMENTO — whmapi1 sem resposta" pendencias_block=" PENDÊNCIAS DE LICENCIAMENTO (fluxo normal — o cliente licencia após a entrega) O cPanel está instalado. Confirmações via API, PHP-FPM para novas contas, backup, provider AutoSSL/tentativa do certificado e cPHulk aguardam a licença: • AUTOMÁTICO: um timer verifica a cada 30 min e completa tudo sozinho assim que a licença ativar (você recebe a notificação de conclusão) • NA HORA: ${INSTALL_PATH} --finish (após licenciar/apontar o DNS) " fi hostname_real="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo n/d)" hostname_display="$HOSTNAME_FQDN" if [[ "$hostname_real" != "$HOSTNAME_FQDN" ]]; then if [[ -f "$HOSTNAME_RECONCILE_REQUIRED_MARKER" ]]; then hostname_display="${HOSTNAME_FQDN} ⓘ DNS PENDENTE: hostname provisório atual = ${hostname_real} O watcher tentará a troca automaticamente a cada 30 min após o registro A apontar para ${SERVER_PUBLIC_IP}. Para antecipar: ${INSTALL_PATH} --finish" pendencias_block+=" PENDÊNCIA DE DNS/HOSTNAME (não bloqueia a instalação) Publique o registro A: ${HOSTNAME_FQDN} → ${SERVER_PUBLIC_IP} O hostname deve ter EXATAMENTE um registro A — em migrações, remova também o(s) apontamento(s) antigo(s), senão a reconciliação nunca conclui. Até a reconciliação automática, use o hostname provisório ou o IP para o WHM. " else hostname_display="${HOSTNAME_FQDN} ⚠ ATENÇÃO: o hostname REAL é ${hostname_real}. Reconcile com: ${INSTALL_PATH} --finish" fi fi if is_valid_fqdn "$hostname_real"; then whm_access_hostname="$hostname_real" else whm_access_hostname="$public_ip" fi # Pré-computa o tamanho atual do /tmp fora do heredoc (evita comando # inline que poderia travar o expansion do heredoc). tmp_current_size="$(df -h /tmp 2>/dev/null | awk 'NR==2 {print $2}')" [[ -z "$tmp_current_size" ]] && tmp_current_size="n/d" local now_ts now_ts="$(date '+%F %T')" cat > "$SUMMARY_FILE" < /opt/cpanel/\$v/root/etc/php.d/imagick.ini done /usr/local/cpanel/scripts/restartsrv_apache_php_fpm • Redis (extensão php-redis; o daemon pode ser instalado com REDIS_ENABLE=1): for v in \$(ls /opt/cpanel | grep ^ea-php); do pkg="\$v-php-redis" if rpm -q "\$pkg" >/dev/null 2>&1; then continue elif dnf -q list --available "\$pkg" >/dev/null 2>&1; then dnf install -y "\$pkg" else yum install -y gcc make /opt/cpanel/\$v/root/usr/bin/pecl install redis echo "extension=redis.so" > /opt/cpanel/\$v/root/etc/php.d/redis.ini fi done /usr/local/cpanel/scripts/restartsrv_apache_php_fpm • Microsoft ODBC + pdo_sqlsrv (PHP 8.1+): curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL \ https://packages.microsoft.com/config/rhel/9/prod.repo \ -o /etc/yum.repos.d/mssql-release.repo ACCEPT_EULA=Y yum install -y msodbcsql18 mssql-tools18 unixODBC-devel for v in \$(ls /opt/cpanel | grep -E '^ea-php8[1-9]\$'); do source /opt/cpanel/\$v/enable /opt/cpanel/\$v/root/usr/bin/pecl install sqlsrv pdo_sqlsrv echo "extension=sqlsrv.so" > /opt/cpanel/\$v/root/etc/php.d/sqlsrv.ini echo "extension=pdo_sqlsrv.so" > /opt/cpanel/\$v/root/etc/php.d/pdo_sqlsrv.ini done /usr/local/cpanel/scripts/restartsrv_apache_php_fpm ================================================================================ SUMMARY # Exibe em tela (vai pro journal do systemd e pro phase2.log) cat "$SUMMARY_FILE" } # ============================================================================ # Dispatcher # ============================================================================ show_help() { cat <