🐛 Caça aos Bugs: Como um toggle "inofensivo" derrubou o nosso servidor DNS (e o que aprendemos com isso)
Se você trabalha com infraestrutura, sabe como as coisas podem sair do controle rápido. Recentemente, implementamos uma feature simples no nosso dashboard (Unbound Sentinel): um toggle para restringir o acesso ao servidor DNS via firewall.
O que deveria ser apenas um allow/deny de IPs se transformou em uma bola de neve de 3 bugs bizarros encadeados que derrubaram completamente o serviço DNS local. Aqui está o postmortem de como investigamos e resolvemos essa cascata.
🔴 Ato 1: O Toggle da Ansiedade (Front-end)
Tudo começou com um reporte visual: ao entrar na tela de Controle de Acesso, o toggle "Restringir Acesso" ficava preso em "CARREGANDO..." infinitamente.
O que estava acontecendo: O nosso frontend, em vez de consultar uma API REST padrão para saber o estado do serviço, tentava dar um "jeitinho": ele lia um arquivo de configuração (access-control.conf) e fazia um parse manual via Regex para inferir se o modo restrito estava ativo ou não.
Se o arquivo estivesse vazio, malformado, ou não existisse, a regex quebrava silenciosamente e a promise ficava pendurada.
A Solução: Simples e clássica. Removemos a "gambiarra" do frontend e criamos um endpoint dedicado /api/dns-acl que retorna o estado real (autoritativo). O frontend agora confia na API e, se algo der errado, exibe um erro amigável.
🔴 Ato 2: O Firewall Invertido (Back-end)
Com o botão funcionando, fomos testar a feature: ativamos o modo "RESTRITO" e adicionamos as subnets permitidas (incluindo 127.0.0.1 para uso local).
O resultado? O servidor ficou público em vez de restrito. Pior ainda: o próprio servidor (via 127.0.0.1) perdeu o acesso ao DNS (connection timed out).
Como um firewall consegue fazer exatamente o oposto do que você manda?
A sobreposição de regras: Nosso código removia as portas 53/tcp e 53/udp da zona public do firewalld. O problema é que o serviço dns (um alias nativo do firewalld) continuava ativado nessa zona. Para o firewalld, serviço tem precedência sobre porta explícita. Então, o servidor continuava aberto pro mundo.
O sumiço do localhost: Quando o usuário adicionava 127.0.0.1/ALLOW, o sistema pegava esse IP e jogava como "source" na zona customizada dns-clients. O firewalld ficava confuso: o tráfego de loopback, que normalmente flui em paz pela zona trusted (associada à interface lo), agora caía numa zona com regras restritas, matando a resolução local do próprio server.
A Solução:
Mudamos a lógica para usar explicitamente --remove-service=dns (e --add-service quando em modo aberto).
Implementamos um filtro hardcoded: ips 127.x.x.x e ::1 nunca vão para zonas customizadas de clientes. O loopback é sagrado e não se mexe.
🔴 Ato 3: A Morte Silenciosa do Unbound (SysAdmin / Systemd)
Toggle funcionando, firewall corrigido. Vamos testar. Usuário clica em "Salvar". O painel avisa: "Salvo com sucesso". E então... todo o servidor DNS morre e não volta mais.
Verificamos o log: systemctl status unbound mostrava timeout. O que quebrou dessa vez?
Aqui o buraco foi mais embaixo e envolveu dois problemas distintos:
Problema A: A traição das quebras de linha no SSH
Para aplicar a lista de IPs permitidos, o Node.js enviava o comando pro servidor via pacote ssh2. Fazíamos algo como: exec("echo 'server:\n access-control: ...' | sudo tee access-control.conf")
Descobrimos da pior forma que o ssh2.exec no Node.js não interpreta \n como quebra de linha real na hora de passar pro bash remoto. O resultado? O arquivo foi gravado como uma única linha de texto enorme. O Unbound lia a primeira linha, batia um syntax error por não encontrar o bloco obrigatório server: formatado corretamente, e abortava.
A Solução A: Mudamos a abordagem. Em vez de usar echo ou heredoc via SSH (que também deu problema), passamos a usar chamadas sftp.writeFile() para injetar o arquivo nativamente, garantindo a integridade dos bytes e das quebras de linha. E de bônus, adicionamos um unbound-checkconf antes do restart — se a configuração gerada quebrar a sintaxe, damos rollback e evitamos a queda do serviço.
Problema B: O arquivo vazio que enganou o systemd
Nós corrigimos a configuração, rodamos systemctl start unbound, e ainda assim o serviço tomava timeout de 18 minutos no start.
Fomos cavar os arquivos de unidade do systemd e encontramos o vilão: um override customizado na imagem ISO de instalação:
ini
[Service]
ExecStartPost=/bin/sh -c 'sleep 2 && [ -f unbound_cache.db ] && unbound-control load_cache < unbound_cache.db || true'
A intenção era boa: salvar e restaurar o cache DNS entre restarts. O problema estava no [ -f ] (verifica se o arquivo existe). Durante nossos testes, o arquivo unbound_cache.db foi criado, mas estava vazio (0 bytes).
O systemd iniciava o Unbound com sucesso, executava o ExecStartPost, via que o arquivo existia, e disparava o load_cache. A ferramenta ficava esperando dados no stdin, mas como o arquivo tinha 0 bytes, ela ficava pendurada. O systemd, obediente, esperava pacientemente até bater o timeout do serviço e matava tudo.
A Solução B: Mudamos o override na ISO para usar [ -s ] (verifica se o arquivo existe E não está vazio). Problema resolvido. O Unbound voltou à vida em menos de 1 segundo.
📝 O que levamos dessa brincadeira?
Valide a sintaxe antes de reiniciar serviços críticos: Nunca, jamais rode um systemctl restart nginx/unbound/apache no seu código backend sem rodar o -t ou equivalente antes.
Cuidado com comandos multilinhas via SSH: Executar scripts bash complexos ou gravar arquivos multilinhas através de APIs de SSH pode mascarar erros sutis de codificação.
Firewalld é cheio de armadilhas: Serviços sobrescrevem portas explícitas, e zonas baseadas em Source IP podem causar curtos-circuitos em tráfego de loopback local se você não filtrar os IPs corretamente.
Scripts de ExecStartPost podem ser perigosos: Se um comando de pós-inicialização travar silenciosamente, seu processo principal pode estar saudável, mas o systemd vai tratá-lo como "Failed" e matar a árvore inteira de processos.
E você, já teve alguma feature "simples" que virou uma cascata de bugs na infra? Conta aí nos comentários!