Desincronização de handshake do MySQL em ambientes multi-tenant: cPanel, CloudLinux e CageFS
Voltar para blog

Desincronização de handshake do MySQL em ambientes multi-tenant: cPanel, CloudLinux e CageFS

22/10/2026 · 9 min · Infraestrutura

Quem administra servidores compartilhados com cPanel, CloudLinux e dezenas de revendedores já deve ter passado por este pesadelo: do nada, dezenas de sites de um mesmo cliente ou revenda começam a exibir erro de conexão com o banco de dados.

O sintoma clássico aparece nos logs do PHP: Access denied for user 'user_db'@'localhost'.

A primeira reação costuma ser verificar se o serviço do MySQL caiu. Mas não: o daemon mysqld ou mariadbd está operando normalmente, outros sites do servidor funcionam sem qualquer instabilidade, e a falha fica restrita a contas pertencentes a um revendedor ou owner específico.

Trocar a senha de cada banco na mão pelo painel resolveria no desespero, mas quebraria a aplicação se ela não for atualizada no arquivo de configuração. Além disso, quando são cinquenta ou cem sites afetados, fazer isso manualmente é inviável.

Neste artigo, vamos dissecar o que causa esse comportamento, como rastrear a origem real do erro com ferramentas do sistema operacional e como resolver o problema em lote com scripts seguros.


1. Anatomia do incidente e análise de causa raiz#

Quando uma aplicação em PHP (como WordPress, Drupal, Magento ou Laravel) tenta abrir uma conexão com o banco de dados, o fluxo de execução cruza várias camadas de abstração e isolamento do Linux:

[Aplicação PHP] -> [PHP-FPM Pool / CageFS LVE Namespace] -> [Socket UNIX / Loopback TCP] -> [MySQL Grant Tables (Memória / InnoDB)]

A perda de sincronia de credenciais restrita a um subconjunto de usuários quase sempre aponta para falhas nas camadas intermediárias de gerenciamento de estado.

Hipótese 1: Dessincronização do mecanismo de mapeamento do cPanel (db-map cache)#

O cPanel não consulta as tabelas internas do MySQL em tempo real para exibir limites e usuários na interface web. Ele mantém um cache indexado em arquivos Berkeley DB (BDB) ou SQLite dentro do diretório /var/cpanel/databases/.

O mecanismo funciona assim: quando ocorre uma alteração de conta (como suspensão e reativação temporária da revenda, troca de pacotes de limites LVE pelo WHM ou atualizações automáticas via upcp), o script /usr/local/cpanel/scripts/update_db_cache é chamado para consolidar permissões e regerar os mapas.

O ponto de falha surge se esse script sofrer interrupção forçada (por falta de memória via OOM Killer, sinal de encerramento prematuro ou corrupção de arquivo durante a escrita). A interface web do cPanel continua listando os usuários do banco, mas os privilégios correspondentes na memória do MySQL ou no arquivo de grants deixam de ser propagados corretamente.

Hipótese 2: Isolamento do sistema de arquivos virtualizado (CageFS opaque mounts)#

O CloudLinux usa o CageFS para isolar cada usuário em seu próprio namespace de sistema de arquivos (chroot avançado baseado em Mount Namespaces do Kernel Linux).

O arquivo de configuração global /etc/my.cnf e o socket de comunicação /var/lib/mysql/mysql.sock são espelhados para dentro do CageFS de cada usuário por meio de montagens do tipo bind (mount --bind).

Se ocorrer uma falha de sincronização no CageFS, ou se o esqueleto base (/usr/share/cagefs-skeleton) tiver sido modificado enquanto existiam sessões ativas do revendedor afetado, os arquivos em /etc/my.cnf.shadow ou as permissões de leitura do socket dentro do namespace podem divergir do ambiente global. O PHP tenta abrir a conexão, o kernel falha em mapear o descritor de arquivo e a camada PDO ou MySQLi do PHP reporta isso genericamente como falha de autenticação.

Hipótese 3: Alterações dinâmicas de escopo globais versus locais (host locking)#

No MySQL e MariaDB, a identidade de um usuário depende da combinação exata de usuário e origem: 'usuario'@'host'.

Aplicações web geralmente conectam informando localhost. No Linux, conectar através da string localhost força o uso de sockets UNIX. Conectar via 127.0.0.1 força o uso da pilha de rede TCP/IP em loopback.

Se as tabelas de privilégios forem reconstruídas de modo que o usuário receba permissão para [email protected], mas não para user_db@localhost, o handshake falha na hora. Além disso, o MySQL varre sua tabela de permissões sequencialmente. Se existir uma entrada genérica ou corrompida (como ''@'localhost') com prioridade de leitura antes da regra do usuário, o MySQL rejeita a conexão com erro de senha inválida, mesmo que a senha fornecida no script esteja certa.


2. Rastreabilidade de erros na camada do PHP e syscalls#

O que acontece no código da aplicação e extensões c#

Quando a aplicação não consegue conectar, a extensão nativa em C do PHP (mysqli ou pdo_mysql) realiza chamadas ao driver de baixo nível (libmysqlclient ou mysqlnd).

No código-fonte do driver nativo do PHP (mysqlnd), a função mysqlnd_connect realiza a negociação inicial. Se o servidor MySQL devolver o pacote de erro ER_ACCESS_DENIED_ERROR (código de erro 1045), a extensão preenche a mensagem de aviso:

// Representação conceitual do tratamento de erro na libmysqlclient / mysqlnd
if (packet->error_no == 1045) {
    php_error_docref(NULL, E_WARNING, "Access denied for user '%s'@'%s' (using password: %s)", ...);
    SET_CONN_STATE(CONN_STATE_CLOSE);
    return FAIL;
}

Parâmetros de compilação e variáveis de ambiente relevantes#


3. Diagnóstico prático no terminal sem suposições#

Antes de alterar qualquer configuração no servidor, o caminho mais seguro é coletar evidências em tempo de execução para separar problemas de permissão no sistema operacional de falhas reais nas tabelas do MySQL.

Passo 1: Inspeção de chamadas do sistema com strace#

Para verificar se o PHP está tentando abrir o socket correto e onde exatamente a conexão trava, executamos um strace simulando o ambiente do usuário afetado dentro de sua jaula:

su -s /bin/bash - user_cpanel -c "strace -f -s 128 -e trace=network,open,openat,connect php -r 'mysqli_connect(\"localhost\", \"user_db\", \"senha_aqui\");'"

Análise do comportamento observado no strace#

O retorno das chamadas de sistema revela o ponto exato da falha:

Cenário A: Problema de isolamento no CageFS

openat(AT_FDCWD, "/var/lib/mysql/mysql.sock", O_RDWR) = -1 ENOENT (No such file or directory)

O arquivo do socket não existe dentro do namespace desse usuário. O problema é de montagem de volumes virtuais do CloudLinux, não de senha.

Cenário B: Problema de permissão no arquivo de socket

connect(3, {sa_family=AF_UNIX, sun_path="/var/lib/mysql/mysql.sock"}, 110) = -1 EACCES (Permission denied)

O socket está visível, mas as permissões do sistema (mysql:mysql contra o grupo do usuário) impedem a abertura do descritor de arquivo.

Cenário C: Falha real de credencial ou tabela de privilégios

connect(3, {sa_family=AF_UNIX, sun_path="/var/lib/mysql/mysql.sock"}, 110) = 0
write(3, "\24\0\0\0\n8.0.35\0...", 24) = 24
read(3, "G\0\0\2\377\25\004#28000Access denied for user 'user_db'@'localhost'...", 16384) = 75

A chamada connect retornou 0 (sucesso). O socket UNIX foi aberto e o kernel realizou a operação de I/O normalmente. Porém, o pacote retornado pelo MySQL contém o erro 28000Access denied. O bloqueio está integralmente nas tabelas internas do banco de dados.

Passo 2: Investigação do estado do MySQL e tabelas de privilégios#

Pelo terminal com privilégios administrativos, consultamos os registros de usuários correspondentes:

mysql -u root -e "SELECT User, Host, plugin, authentication_string FROM mysql.user WHERE User LIKE 'user\_%';"

Se a coluna plugin estiver definida como auth_socket ou unix_socket em vez de caching_sha2_password ou mysql_native_password, o MySQL ignora a senha informada no código PHP e tenta validar se o UID do processo no Linux confere exatamente com o nome do usuário do banco, quebrando conexões normais de CMSs.


4. Cenários de borda e como descartá-los#

Cenário de borda 1: Exaustão de conexões mascarada como falha de autenticação#

Quando uma conta atinge o limite configurado de conexões simultâneas (MAX_USER_CONNECTIONS), algumas versões de drivers do PHP fecham a conexão abruptamente e reportam erro de autenticação.

Para checar os limites globais e as conexões ativas por usuário:

mysql -u root -e "SHOW GLOBAL VARIABLES LIKE 'max_user_connections';"
mysql -u root -e "SELECT USER, COUNT(*) FROM INFORMATION_SCHEMA.PROCESSLIST GROUP BY USER;"

Cenário de borda 2: Diferenciação de maiúsculas e minúsculas com lower_case_table_names#

Se o arquivo global /etc/my.cnf foi atualizado e o parâmetro lower_case_table_names mudou de valor (por exemplo, de 0 para 1), o MySQL altera o cálculo de hashes e comparações de identificadores. Usuários ou bancos criados com letras maiúsculas podem perder o mapeamento de privilégios.

Para verificar o valor atual:

mysqladmin variables | grep lower_case_table_names

5. Decomposição de comandos e onde o Bash costuma pregar peças#

Durante incidentes sob pressão, é comum tentar listar domínios e testar requisições em uma única linha no terminal. No entanto, pequenos descuidos na sintaxe do Bash produzem resultados caóticos.

Vamos analisar duas construções que frequentemente falham:

# Exemplo 1 com erro de sintaxe comum
USERS=$(grep conected /etc/trueuserowners | awk -F: '{print $1}') && grep -ri $USERS /etc/trueuserdomains
# Exemplo 2 com erro de aspas e loop quebrado
grep -E "$(grep ': conected' /etc/trueuserowners | cut -d: -f1 | tr '\n' '|' | sed 's/|$//')" /etc/trueuserdomains | awk -F: '{print $1}' && for CHECKSITE in $(echo '\n$DOMAINS); do curl -sklI $CHECKSITE | grep HTTP; done

Por que a sintaxe original falhou#

  1. Expansão de variável sem aspas (word splitting): No primeiro comando, a variável USERS contém vários nomes de usuários separados por quebras de linha (\n). Ao chamar $USERS sem aspas no comando seguinte, o interpretador quebra a lista em múltiplos argumentos. O comando executado passa a ser:
grep -ri user1 user2 user3 /etc/trueuserdomains

Para o binário do grep, o primeiro argumento (user1) é o termo de busca, enquanto todos os outros (user2, user3) são tratados como caminhos de arquivos onde buscar. O resultado é uma enxurrada de mensagens de erro No such file or directory.

  1. Flag de recursão em arquivo simples: O arquivo /etc/trueuserdomains é um arquivo de texto plano. Usar -r (recursivo) faz o utilitário esperar diretórios para varrer, o que não faz sentido para um arquivo estático.
  1. Aspas simples abertas sem fechamento: No segundo comando, a expressão $(echo '\n$DOMAINS) usa aspas simples que impedem a expansão da variável e deixam a string sem fechamento, travando o analisador do Bash antes mesmo do loop iniciar.

6. Scripts defensivos para diagnóstico e recuperação em lote#

Aqui estão dois scripts desenvolvidos para resolver o problema com segurança e sem alterar senhas para valores aleatórios, o que quebraria os sites dos clientes.

Script 1: Verificação rápida de status HTTP para identificar sites fora do ar#

Este comando extrai os domínios da revenda afetada e executa uma consulta HTTP HEAD leve para apontar quais sites estão retornando erro 500:

DOMAINS=$(grep -E "$({ grep ': conected' /etc/trueuserowners || echo "NENHUM_OWNER_ENCONTRADO"; } | cut -d: -f1 | tr '\n' '|' | sed 's/|$//')" /etc/trueuserdomains | awk -F: '{print $1}') && for site in $DOMAINS; do printf "%-40s : " "$site"; STATUS=$(curl -Is --connect-timeout 4 --max-time 6 -o /dev/null -w "%{http_code}" "http://$site"); if [ "$STATUS" -eq 200 ]; then echo -e "\e[32m$STATUS OK\e[0m"; else echo -e "\e[31m$STATUS ERRO\e[0m"; fi; done

O comando funciona porque:

Script 2: Extração automática de credenciais e sincronização de privilégios#

Este script lê a senha configurada no arquivo da aplicação (wp-config.php ou configuration.php), descobre o usuário do banco e utiliza as rotinas do cPanel para atualizar os hashes no MySQL sem alterar as senhas dos sites.

#!/bin/bash
# ==============================================================================
# Sincronização em lote de privilégios MySQL para revendas no cPanel
# ==============================================================================

TARGET_OWNER="conected"
TRUEUSEROWNERS_FILE="/etc/trueuserowners"
TRUEUSERDOMAINS_FILE="/etc/trueuserdomains"

# Validação de privilégios de execução
if [ "$EUID" -ne 0 ]; then
    echo "[-] Erro: Este script precisa ser executado como root."
    exit 1
fi

echo "[+] Coletando contas vinculadas ao revendedor: $TARGET_OWNER"
USERS=$(grep -E ": $TARGET_OWNER$" "$TRUEUSEROWNERS_FILE" | cut -d: -f1)

if [ -z "$USERS" ]; then
    echo "[-] Nenhum usuário encontrado para o revendedor especificado."
    exit 0
fi

echo "[+] Iniciando processamento em massa..."
echo "------------------------------------------------------------------------"

for user in $USERS; do
    # Obtém dinamicamente o diretório home via getent para evitar caminhos fixos
    HOMEDIR=$(getent passwd "$user" | cut -d: -f6)

    if [ ! -d "$HOMEDIR" ]; then
        echo "[-] [USUARIO: $user] Diretório home não encontrado: $HOMEDIR"
        continue
    fi

    # Arquivos padrão de configuração dos principais CMSs
    CONFIG_PATHS=(
        "$HOMEDIR/public_html/wp-config.php"
        "$HOMEDIR/public_html/configuration.php"
    )

    for config in "${CONFIG_PATHS[@]}"; do
        if [ -f "$config" ]; then
            echo "[*] [USUARIO: $user] Analisando configuração: $config"

            # Extração de usuário e senha via expressão regular com fallback de aspas
            DB_USER=$(grep -E "DB_USER|dbuser" "$config" | head -n 1 | awk -F"'" '{print $4}' || true)
            if [ -z "$DB_USER" ]; then
                DB_USER=$(grep -E "DB_USER|dbuser" "$config" | head -n 1 | awk -F'"' '{print $4}' || true)
            fi

            DB_PASS=$(grep -E "DB_PASSWORD|dbpass" "$config" | head -n 1 | awk -F"'" '{print $4}' || true)
            if [ -z "$DB_PASS" ]; then
                DB_PASS=$(grep -E "DB_PASSWORD|dbpass" "$config" | head -n 1 | awk -F'"' '{print $4}' || true)
            fi

            # Quando as credenciais são encontradas, injeta o hash via script oficial do cPanel
            if [ -n "$DB_USER" ] && [ -n "$DB_PASS" ]; then
                echo "    -> Usuário do banco identificado: $DB_USER"
                echo "    -> Sincronizando hash de credenciais com o MySQL..."

                /usr/local/cpanel/scripts/set_mysql_password --user="$DB_USER" --password="$DB_PASS" > /dev/null 2>&1

                if [ $? -eq 0 ]; then
                    echo "    -> [SUCESSO] Credenciais sincronizadas com êxito."
                else
                    echo "    -> [FALHA] Não foi possível atualizar a senha para $DB_USER."
                fi
            fi
        fi
    done
done

echo "------------------------------------------------------------------------"
echo "[+] Atualizando caches e remontando namespaces..."

# Reconstrói os mapas internos de banco de dados do cPanel
if [ -x "/usr/local/cpanel/scripts/update_db_cache" ]; then
    echo "[*] Atualizando cache de banco de dados do cPanel..."
    /usr/local/cpanel/scripts/update_db_cache
fi

# Remonta os namespaces virtuais do CageFS
if command -v cagefsctl &> /dev/null; then
    echo "[*] Atualizando esqueleto e remontando o CageFS..."
    cagefsctl --force-update > /dev/null 2>&1
    cagefsctl --remount-all > /dev/null 2>&1
    echo "[+] CageFS sincronizado e remontado com sucesso."
fi

# Aplica o flush de privilégios no MySQL
echo "[*] Executando FLUSH PRIVILEGES no motor do banco de dados..."
mysql -e "FLUSH PRIVILEGES;"

echo "[+] Processo em lote concluído com sucesso."

7. Arquivos de log e rotinas preventivas para manter o ambiente estável#

Caminhos fundamentais para monitoramento e auditoria#

Para acompanhar o comportamento do banco de dados e diagnosticar falhas de comunicação futuras, estes são os principais registros a observar:

Cuidados essenciais no dia a dia#

  1. Evite redefinir senhas manualmente sem ler o arquivo de configuração: trocar senhas diretamente pelo phpMyAdmin ou WHM faz a aplicação parar de conectar até que os arquivos locais sejam editados um a um.
  2. Cheque o socket antes de culpar credenciais: quase metade das falhas que parecem de senha em servidores com CloudLinux são, na prática, descompassos de montagem do socket dentro do CageFS.
  3. Mantenha o cache do cPanel sincronizado: se fizer alterações manuais em permissões de banco pelo MySQL CLI, execute /usr/local/cpanel/scripts/update_db_cache em seguida para evitar que a interface do painel sobrescreva suas alterações na rotina diária de manutenção.

Este artigo foi útil?

Deixe uma reação rápida para apoiar o conteúdo:

CC BY-NC

Este post está licenciado sob CC BY-NC.

Comentários

Participe da discussão abaixo.

0 comentários