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:
- Testar exatamente o mesmo domínio no backend novo;
- Não alterar DNS global antes da homologação estar 100% concluída;
- Evitar ajuste manual de
hostspor máquina/estação de trabalho; - Comparar a resposta legado vs novo no mesmo instante.
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:
config.php: IPs, portas dos ambientes e validações de infraestrutura básica;dev.php: Interface visual com dois iframes lado a lado (andamento e fortis);proxy.php: Motor de roteamento, controle de concorrência, cURL com tratamento de cookies, logging e limites de segurança.
Fluxo da requisição:
- O operador informa o domínio + caminho no painel de visualização (
dev.php); dev.phpmonta duas requisições chamando o scriptproxy.php;proxy.phpvalida a autenticação, rate limit, sanitiza a URI e resolve o IP do servidor alvo;- O cURL conecta ao IP e envia o cabeçalho
Hostoriginal e os cookies do cliente; - O Apache de destino processa a requisição usando o bloco
VirtualHostcorreto; - 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"#
- Sintoma: Servidor novo devolvia página default do Apache.
- Causa raiz: Request chegou por IP, mas o Apache não encontrou
ServerNameouServerAliascompatível com domínio enviado no Host header. - Correção:
- Ajustar o bloco vhost com
ServerNamee aliases corretos; - Garantir que o
DocumentRootestá apontando para o caminho correto; - Recarregar o Apache e validar com
curl -H "Host: ...".
5.2 assets quebrados (CSS/JS/imagem)#
- Sintoma: O HTML abriu, mas os links de assets iam para o domínio público e não para o backend de homologação.
- Correção:
- Injetar
<base href="http://IP_ALVO/">dentro do<head>; - Revalidar páginas com caminhos relativos.
5.3 texto puro com comportamento errado#
- Sintoma: Endpoint
/teste.txtretornava interpretado como HTML. - Correção:
- Detectar
Content-Type: text/plainno retorno do cURL; - Responder o body diretamente ao cliente, sem aplicar a injeção da tag base.
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.
- Verificação de SSL/TLS (MITM Protection): Desabilitar a validação do cURL (
CURLOPT_SSL_VERIFYPEER => false) é um risco grave. Use a validação ativa com o CA Bundle correto em produção e permita desabilitar condicionalmente apenas quando o ambiente for explicitamente de desenvolvimento (APP_ENV === 'development'). - Remoção de Assinaturas de Servidor: Cabeçalhos como
X-Powered-Bydevem ser removidos e a exibição de erros na tela (display_errors) desativada no PHP para mitigar a coleta de informações (fingerprinting). - Validação de Entrada com Regex: Parâmetros passados via GET/POST devem ser rigidamente validados contra padrões seguros de URIs.
- Prevenção de Abuso: Limitar a taxa de requisições baseada em sessões (Rate Limiting) e estipular um tamanho máximo para as respostas transferidas evita esgotamento de memória e ataques DoS.
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:
- [ ] Autenticação: HTTP Basic Auth ou API Key configurados de forma obrigatória.
- [ ] Segredos: Credenciais e chaves carregadas via variáveis de ambiente (
PROXY_API_KEY,PROXY_PASSWORD), sem hardcode. - [ ] Rede: Acesso restrito a blocos de IPs específicos via firewall ou regras do servidor web.
- [ ] Criptografia: Tráfego forçado sob protocolo HTTPS com validação SSL ativa (
CURLOPT_SSL_VERIFYPEER => true). - [ ] Proteção: Rate limiting ativo e tamanho máximo de resposta configurado.
- [ ] Limitação de Entrada: Parâmetro
urisanitizado contra injeções. - [ ] Logging: Logs de requisições ativados e gravados em diretório protegido
/var/log/proxy-migration.log. - [ ] Deploy: Arquivos php com permissão de leitura restrita
640e pertencentes ao usuáriowww-data.
10) Runbook de homologação antes do cutover DNS#
- Criar endpoint sentinela apenas no host novo (
/teste.txt); - Validar carregamento legado vs novo no painel comparativo;
- Validar login, sessão e logout (com repasse de cookies);
- Validar formulários críticos (cadastro, contato, checkout);
- Validar uploads e downloads;
- Validar redirecionamentos 301/302 e páginas 404;
- Validar assets e dependências JS/CSS;
- Registrar divergências e corrigir no host novo;
- Repetir o ciclo até atingir a paridade funcional total;
- 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:
Este post está licenciado sob CC BY-NC.



Comentários
Participe da discussão abaixo.
0 comentários