Como criar um proxy inteligente para comparar servidores durante a migração
Voltar para blog

Como criar um proxy inteligente para comparar servidores durante a migração

07/06/2026 · 6 min · Infraestrutura

Quando você migra um site entre servidores, o maior risco não é o rsync. O maior risco é homologar o servidor novo sem trocar DNS público e sem depender que cada pessoa do time edite hosts manualmente.

Neste case, implementei uma camada de proxy em PHP para comparar o ambiente legado (andamento) e o ambiente novo (fortis) lado a lado, usando o mesmo host lógico da aplicação. O objetivo foi validar o comportamento real de VirtualHost, sessões, formulários e recursos estáticos antes do cutover definitivo de DNS.

1) Problema real de migração que eu precisava resolver#

A URL de produção estava funcionando, mas o ambiente novo ainda não podia receber tráfego público. Eu precisava:

Esse é o ponto onde o proxy inteligente entra: ele conecta no IP alvo, mas força o cabeçalho Host: domain.com na requisição cURL para que o servidor Apache de destino responda pelo VirtualHost correspondente.

2) Arquitetura aplicada (config.php + dev.php + proxy.php)#

Estrutura de arquivos implementada:

Fluxo da requisição:

  1. O operador informa o domínio + caminho no painel de visualização (dev.php);
  2. dev.php monta duas requisições chamando o script proxy.php;
  3. proxy.php valida a autenticação, rate limit, sanitiza a URI e resolve o IP do servidor alvo;
  4. O cURL conecta ao IP e envia o cabeçalho Host original e os cookies do cliente;
  5. O Apache de destino processa a requisição usando o bloco VirtualHost correto;
  6. O conteúdo retorna de forma isolada em cada iframe correspondente.

3) Código completo de apoio#

3.1 config.php#

<?php
// Ambientes de comparação
const ANDAMENTO_IP = '10.10.10.11';
const FORTIS_IP    = '10.10.10.12';

// Opcional: portas diferentes por ambiente
const DEFAULT_HTTP_PORT  = 80;
const DEFAULT_HTTPS_PORT = 443;

// Validação sintática e de segurança para garantir IPs estruturados válidos
function validateIP(string $ip): bool {
    return filter_var($ip, FILTER_VALIDATE_IP) !== false;
}

if (!validateIP(ANDAMENTO_IP) || !validateIP(FORTIS_IP)) {
    http_response_code(500);
    die('Erro de Configuração: IPs de destino configurados são inválidos.');
}

3.2 dev.php (painel comparativo)#

<?php
$uri = $_GET['uri'] ?? 'domain.com/';
$uri = trim($uri);
$key = $_GET['key'] ?? '';
$key = preg_replace('/[^a-zA-Z0-9\-]/', '', $key); // Sanitização básica da chave
?>
<!doctype html>
<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Comparador de Migração</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 0; background-color: #f5f5f5; }
    .bar { padding: 12px; border-bottom: 1px solid #ddd; background-color: #fff; }
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: calc(100vh - 70px); }
    iframe { width: 100%; height: 100%; border: 0; background: #fff; }
    input { width: 60%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
    button { padding: 8px 12px; background-color: #007bff; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
    button:hover { background-color: #0056b3; }
  </style>
</head>
<body>
  <div class="bar">
    <form method="get">
      <label>URI (domínio + caminho):</label>
      <input name="uri" value="<?= htmlspecialchars($uri, ENT_QUOTES, 'UTF-8') ?>" placeholder="exemplo.com/pagina">
      <?php if (!empty($key)): ?>
        <input type="hidden" name="key" value="<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
      <?php endif; ?>
      <button type="submit">Comparar</button>
    </form>
  </div>

  <div class="grid">
    <iframe src="proxy.php?server=andamento&uri=<?= urlencode($uri) ?><?= $key ? '&key=' . urlencode($key) : '' ?>"></iframe>
    <iframe src="proxy.php?server=fortis&uri=<?= urlencode($uri) ?><?= $key ? '&key=' . urlencode($key) : '' ?>"></iframe>
  </div>
</body>
</html>

3.3 proxy.php (versão de campo blindada)#

<?php
// Configurações básicas de segurança e ocultação de assinaturas
ini_set('display_errors', 0);
ini_set('log_errors', 1);
header_remove('X-Powered-By');

// Headers de Hardening e Proteção (CSP restritiva removida para espelhar vhost original)
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');

require 'config.php';

// 1) Autenticação Obrigatória do Proxy (Sem Fallbacks Inseguros)
$API_KEY = $_SERVER['HTTP_X_API_KEY'] ?? $_GET['key'] ?? '';
$VALID_KEY = getenv('PROXY_API_KEY');

if (empty($VALID_KEY)) {
    http_response_code(500);
    die('Erro de configuração: PROXY_API_KEY não definida no ambiente.');
}

if ($API_KEY !== $VALID_KEY) {
    // Fallback de contingência para HTTP Basic Auth
    $USER = $_SERVER['PHP_AUTH_USER'] ?? '';
    $PASS = $_SERVER['PHP_AUTH_PASS'] ?? '';
    $VALID_PASS = getenv('PROXY_PASSWORD');
    if (empty($VALID_PASS) || $USER !== 'admin' || $PASS !== $VALID_PASS) {
        http_response_code(401);
        header('WWW-Authenticate: Basic realm="Proxy de Migração"');
        die('Acesso não autorizado: credenciais ausentes ou inválidas.');
    }
}

// 2) Rate Limiting Robusto por IP (APCu com fallback para Sessão com IP-binding)
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$rateKey = "proxy_rate_limit:" . md5($ip);
$limit = 100; // Requisições máximas por hora
$window = 3600; // Janela de 1 hora
$isLimited = false;

if (function_exists('apcu_fetch')) {
    $requests = apcu_fetch($rateKey) ?: 0;
    if ($requests >= $limit) {
        $isLimited = true;
    } else {
        apcu_store($rateKey, $requests + 1, $window);
    }
} else {
    session_start();
    $_SESSION['requests'] = $_SESSION['requests'] ?? [];
    $_SESSION['requests'] = array_filter($_SESSION['requests'], function($t) use ($window) {
        return $t > time() - $window;
    });
    if (count($_SESSION['requests']) >= $limit) {
        $isLimited = true;
    } else {
        $_SESSION['requests'][] = time();
    }
}

if ($isLimited) {
    http_response_code(429);
    die('Limite de requisições excedido. Tente novamente mais tarde.');
}

// 3) Validação Rígida e Proteção SSRF
$server = $_GET['server'] ?? '';
$uri    = $_GET['uri'] ?? '';

$servers = [
    'andamento' => ['ip' => ANDAMENTO_IP, 'http_port' => DEFAULT_HTTP_PORT, 'https_port' => DEFAULT_HTTPS_PORT],
    'fortis'    => ['ip' => FORTIS_IP,    'http_port' => DEFAULT_HTTP_PORT, 'https_port' => DEFAULT_HTTPS_PORT],
];

if (!isset($servers[$server])) {
    http_response_code(400);
    die('Servidor de destino inválido.');
}

$normalizedUri = trim($uri);
if (strpos($normalizedUri, '://') === false) {
    $normalizedUri = 'http://' . $normalizedUri;
}

if (!filter_var($normalizedUri, FILTER_VALIDATE_URL)) {
    http_response_code(400);
    die('URI inválida.');
}

$parsed = parse_url($normalizedUri);
$domain = $parsed['host'] ?? '';
$path   = $parsed['path'] ?? '/';

if (!empty($parsed['query'])) {
    $path .= '?' . $parsed['query'];
}

// Mitiga SSRF bloqueando hosts IP diretos e loopbacks
if (empty($domain) || filter_var($domain, FILTER_VALIDATE_IP)) {
    http_response_code(400);
    die('Domínio de destino inválido ou proibido.');
}

$srv = $servers[$server];

// 4) Definição de limites de tamanho
define('MAX_RESPONSE_SIZE', 10 * 1024 * 1024); // Limite de 10MB

function executeCurl(string $url, string $domain, array &$responseCookies, ?string &$locationHeader): array
{
    $ch = curl_init();
    
    $client_cookies = [];
    foreach ($_COOKIE as $name => $value) {
        $client_cookies[] = "$name=$value";
    }
    $cookie_string = implode('; ', $client_cookies);

    $opts = [
        CURLOPT_URL => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => false, // Desabilitado contra SSRF via redirecionamento
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_USERAGENT => 'migration-proxy/1.0',
        CURLOPT_HTTPHEADER => [
            'Host: ' . $domain,
            'Accept: */*',
            'Accept-Encoding: gzip, deflate',
            'Connection: keep-alive',
            'Cache-Control: no-cache',
            'Pragma: no-cache',
        ],
        CURLOPT_ENCODING => 'gzip, deflate',
        CURLOPT_HEADERFUNCTION => function($ch, $headerLine) use (&$responseCookies, &$locationHeader) {
            $len = strlen($headerLine);
            $parts = explode(':', $headerLine, 2);
            if (count($parts) === 2) {
                $name = strtolower(trim($parts[0]));
                $value = trim($parts[1]);
                if ($name === 'set-cookie') {
                    $responseCookies[] = $value;
                } elseif ($name === 'location') {
                    $locationHeader = $value;
                }
            }
            return $len;
        }
    ];

    if (!empty($cookie_string)) {
        $opts[CURLOPT_COOKIE] = $cookie_string;
    }

    if (getenv('APP_ENV') === 'development') {
        $opts[CURLOPT_SSL_VERIFYPEER] = false;
        $opts[CURLOPT_SSL_VERIFYHOST] = false;
    } else {
        $opts[CURLOPT_SSL_VERIFYPEER] = true;
        $opts[CURLOPT_SSL_VERIFYHOST] = 2;
        $caPath = '/etc/ssl/certs/ca-certificates.crt';
        if (file_exists($caPath)) {
            $opts[CURLOPT_CAINFO] = $caPath;
        }
    }

    curl_setopt_array($ch, $opts);

    $response = curl_exec($ch);
    $error    = curl_error($ch);
    $errno    = curl_errno($ch);
    $code     = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $type     = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'text/html';

    curl_close($ch);

    return [
        'body' => $response,
        'error' => $error,
        'errno' => $errno,
        'code' => $code,
        'type' => $type,
    ];
}

function rewriteRedirectUrl(string $location, string $server, string $domain, string $apiKey): string {
    $parsedLocation = parse_url($location);
    $locationHost = $parsedLocation['host'] ?? '';
    
    if (empty($locationHost) || strcasecmp($locationHost, $domain) === 0) {
        $path = $parsedLocation['path'] ?? '/';
        if (!empty($parsedLocation['query'])) {
            $path .= '?' . $parsedLocation['query'];
        }
        $newUri = $domain . $path;
        $params = ['server' => $server, 'uri' => $newUri];
        if (!empty($apiKey)) {
            $params['key'] = $apiKey;
        }
        return 'proxy.php?' . http_build_query($params);
    }
    return $location;
}

// 1) Tenta HTTP primeiro
$scheme = 'http';
$target = $scheme . '://' . $srv['ip'] . ':' . $srv['http_port'] . $path;
$responseCookies = [];
$locationHeader = null;
$result = executeCurl($target, $domain, $responseCookies, $locationHeader);

// 2) Fallback HTTPS se erro de rede ou HTTP >= 400
if ($result['errno'] !== 0 || $result['code'] >= 400) {
    $scheme = 'https';
    $target = $scheme . '://' . $srv['ip'] . ':' . $srv['https_port'] . $path;
    $responseCookies = [];
    $locationHeader = null;
    $result = executeCurl($target, $domain, $responseCookies, $locationHeader);
}

// 5) Logging para auditoria com bloqueio exclusivo de escrita
$log_entry = [
    'timestamp' => date('c'),
    'client_ip' => $ip,
    'server' => $server,
    'uri' => $uri,
    'status' => $result['code'],
    'error_code' => $result['errno'],
];
@file_put_contents(
    '/var/log/proxy-migration.log',
    json_encode($log_entry) . PHP_EOL,
    FILE_APPEND | LOCK_EX
);

if ($result['body'] === false || $result['body'] === null || $result['errno'] !== 0) {
    http_response_code(502);
    header('Content-Type: text/plain; charset=utf-8');
    echo "Proxy falhou ao buscar conteúdo\n";
    echo "target: {$target}\n";
    echo "curl_errno: {$result['errno']}\n";
    echo "curl_error: {$result['error']}\n";
    exit;
}

$response    = $result['body'];
$contentType = $result['type'];

// Validar Limites de Tamanho de Resposta
if (strlen($response) > MAX_RESPONSE_SIZE) {
    http_response_code(413);
    die('Resposta excede tamanho máximo permitido (10MB).');
}

// Propagar cookies recebidos
foreach ($responseCookies as $cookie) {
    header("Set-Cookie: $cookie", false);
}

// Roteamento seguro de redirecionamentos (Redirect propagation)
if ($locationHeader && $result['code'] >= 300 && $result['code'] < 400) {
    $redirectUrl = rewriteRedirectUrl($locationHeader, $server, $domain, $API_KEY);
    header("Location: $redirectUrl", true, $result['code']);
    exit;
}

// Texto puro (ex.: teste.txt)
if (stripos($contentType, 'text/plain') !== false) {
    header('Content-Type: text/plain; charset=utf-8');
    echo $response;
    exit;
}

// Injeção segura da tag base usando DOMDocument para evitar quebras por espaços/case-sensitivity
$dom = new DOMDocument();
$libxml_disable = false;
if (\PHP_VERSION_ID < 80000 && function_exists('libxml_disable_entity_loader')) {
    $libxml_disable = libxml_disable_entity_loader(true);
}

$htmlToLoad = mb_convert_encoding($response, 'HTML-ENTITIES', 'UTF-8');
@$dom->loadHTML($htmlToLoad, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING);

if ($libxml_disable) {
    libxml_disable_entity_loader(false);
}

$base = $scheme . '://' . $srv['ip'] . '/';
$head = $dom->getElementsByTagName('head')->item(0);
if ($head) {
    $baseElement = $dom->createElement('base');
    $baseElement->setAttribute('href', $base);
    $head->insertBefore($baseElement, $head->firstChild);
}
$response = $dom->saveHTML();

header('Content-Type: text/html; charset=utf-8');
echo $response;

4) Comandos de validação que usei no troubleshooting#

Antes de culpar o código do proxy, valide o virtual host e a rota HTTP/HTTPS no backend de destino.

4.1 validar vhost Apache#

# Exibir mapeamentos de VHosts ativos
apachectl -S

# Testar integridade sintática dos arquivos de configuração
apachectl -t

4.2 validar resposta do backend forçando host no cURL#

# Testa servidor legado (andamento)
curl -sv "http://10.10.10.11/" -H "Host: domain.com" -o /dev/null

# Testa servidor novo (fortis)
curl -sv "http://10.10.10.12/" -H "Host: domain.com" -o /dev/null

4.3 validar arquivo sentinela criado só no servidor novo#

curl -sv "http://10.10.10.12/teste.txt" -H "Host: domain.com"

Se o teste.txt não aparece no painel do proxy para o novo host, o problema não é DNS público: é mapeamento de vhost, docroot ou regra de firewall local no backend de destino.

5) Erros reais que peguei e como corrigi#

5.1 "webserver is functioning normally"#

5.2 assets quebrados (CSS/JS/imagem)#

5.3 texto puro com comportamento errado#

6) Deploy seguro do proxy e configuração do servidor#

Para colocar o comparador de migração em funcionamento, isole-o do diretório público principal e proteja seus arquivos com permissões restritas.

6.1 deploy físico e permissões#

Crie um diretório dedicado e atribua a posse ao usuário do servidor web (ex: www-data no Debian/Ubuntu):

# Criar diretório dedicado fora da raiz do site público
sudo mkdir -p /var/www/proxy-migration
sudo chown www-data:www-data /var/www/proxy-migration

# Copiar os scripts para a pasta
sudo cp config.php dev.php proxy.php /var/www/proxy-migration/

# Configurar permissões restritas (apenas leitura pelo webserver)
sudo chmod 640 /var/www/proxy-migration/*.php
sudo chown www-data:www-data /var/www/proxy-migration/*.php

6.2 configuração do virtualhost do Apache para o proxy#

Abaixo está o exemplo de arquivo de VirtualHost do Apache (/etc/apache2/sites-available/proxy-migration.conf). Note que ele restringe o acesso por subrede/IP e exige autenticação Basic Auth com um arquivo de senhas protegido:

<VirtualHost *:80>
    ServerName proxy.empresa.com
    DocumentRoot /var/www/proxy-migration
    
    <Directory /var/www/proxy-migration>
        AllowOverride None
        # Restringir o acesso a redes locais/VPNs de desenvolvimento
        Require ip 10.0.0.0/8 192.168.0.0/16
    </Directory>
    
    # Autenticação básica obrigatória
    <Directory /var/www/proxy-migration>
        AuthType Basic
        AuthName "Proxy de Migracao"
        AuthUserFile /etc/apache2/.htpasswd
        Require valid-user
    </Directory>
</VirtualHost>

Comandos para habilitar o site e gerar as credenciais:

# Gerar arquivo de senhas básico do Apache
sudo htpasswd -c /etc/apache2/.htpasswd admin

# Ativar a nova configuração e recarregar o Apache
sudo a2ensite proxy-migration.conf
sudo apachectl -t && sudo systemctl reload apache2

7) Hardening e boas práticas de segurança#

Esse tipo de ferramenta expõe dados de roteamento interno e deve ser mantido de forma extremamente restrita.

8) Roteiro de testes do proxy#

Para homologar se o proxy e o ambiente estão operando adequadamente, execute os seguintes testes via CLI:

# 1. Testar se o proxy está respondendo e exige autenticação
curl -I http://proxy.empresa.com/dev.php

# 2. Testar o acesso com a API Key de desenvolvimento configurada
curl -H "X-API-Key: minha-chave-secreta" http://proxy.empresa.com/dev.php?uri=domain.com/

# 3. Testar a autenticação básica através do cURL
curl -u admin:senha http://proxy.empresa.com/dev.php?uri=domain.com/

# 4. Validar se as requisições estão gerando logs adequadamente
tail -f /var/log/proxy-migration.log

9) Checklist: segurança do proxy de migração#

Utilize esta lista operacional de auditoria de segurança antes de abrir o proxy para o time de desenvolvimento/QA:

10) Runbook de homologação antes do cutover DNS#

  1. Criar endpoint sentinela apenas no host novo (/teste.txt);
  2. Validar carregamento legado vs novo no painel comparativo;
  3. Validar login, sessão e logout (com repasse de cookies);
  4. Validar formulários críticos (cadastro, contato, checkout);
  5. Validar uploads e downloads;
  6. Validar redirecionamentos 301/302 e páginas 404;
  7. Validar assets e dependências JS/CSS;
  8. Registrar divergências e corrigir no host novo;
  9. Repetir o ciclo até atingir a paridade funcional total;
  10. Só então aprovar a alteração do DNS público.

11) Resultado operacional#

Com o proxy inteligente estruturado com controles de segurança adequados, a homologação deixou de depender de ajuste manual em cada estação de trabalho e passou a ser centralizada, reprodutiva e auditável.

O cutover de DNS é executado com base em evidências reais de paridade de comportamento dos VirtualHosts, reduzindo a zero os incidentes típicos de indisponibilidade pós-migraçã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