← Anotações & Leituras

Tailscale no 5G, trocar de operadora não resolve

Por uns dois anos eu não consegui acessar a minha rede de casa pelo celular. Em Wi-Fi, de casa ou do escritório, tudo funcionava. Nos dados móveis, não. Eu estava na Vivo e conclui o óbvio: problema da operadora. Troquei para a TIM, em parte por causa disso. Não adiantou.

O que mais me despistou é que a conexão abria. O ping respondia, a porta aparecia como aberta, o SSH até mostrava o banner inicial. Só que o Home Assistant dava timeout, o SSH travava logo em seguida e cópia de arquivo congelava no meio. Isso tem cara de proxy reverso mal configurado ou de certificado, não de rede.

Nesses dois anos eu mexi na configuração da tailnet, habilitei e desabilitei o exit node, tentei acessar por IP e por nome, por HTTP e por HTTPS. Nenhuma dessas tentativas chegou perto, porque todas estavam na camada errada.

Era MTU (Maximum Transmission Unit), o tamanho máximo de pacote que um caminho de rede aceita.

Por que o pacote sumia sem ninguém avisar

A interface do Tailscale usava MTU de 1280 bytes. O caminho pelo 5G só passava 1200. Tudo acima disso era descartado — e, o mais importante, descartado em silêncio.

Isso explica o comportamento que parecia contraditório:

Ou seja, eu conseguia conectar em tudo e não recebia nada.

O detalhe que faz isso ser invisível

Numa conexão TCP comum, os dois lados combinam no início o tamanho máximo de segmento, o MSS (Maximum Segment Size), cada um olhando o MTU da sua interface de saída. Essa negociação é honesta: a interface que o TCP consulta é a mesma que vai colocar o pacote no fio.

Com uma VPN, não é. O meu TCP olhava a interface do Tailscale, via 1280 e concluía que estava seguro. Contudo, aquele pacote ainda seria embrulhado pelo WireGuard antes de sair de verdade:

[ IPv6 40 + UDP 8 + WireGuard 32 ] + [ pacote interno 1280 ] = 1360 no fio

O TCP de dentro nunca fica sabendo desses 80 bytes de cabeçalho. Ele negociou corretamente para um link que não existe.

Por que só a VPN quebra, e o resto da internet não

Essa foi a parte que me deixou mais tempo procurando no lugar errado. Se o caminho móvel é ruim, por que só o meu acesso de casa sofria, e nenhum site normal?

O caminho móvel também tem MTU reduzido para o tráfego comum: a rede da operadora encapsula tudo internamente, no núcleo 5G, e isso come algumas centenas de bytes. A diferença é que a operadora muito provavelmente aplica MSS clamping no gateway dela. Ela intercepta o início das suas conexões TCP e reescreve o MSS para um valor que cabe. Você nunca percebe.

No entanto, ela só consegue fazer isso com o que ela enxerga. O tráfego do Tailscale sai como UDP opaco e criptografado, então não há como ver que existe um TCP lá dentro, muito menos reescrever o MSS dele. O conserto automático que protege o resto da minha internet simplesmente não alcança a VPN.

Sabendo disso, trocar de operadora nunca poderia ter funcionado.

E o mecanismo que existe justamente para isso?

Existe: o PMTU discovery (Path MTU Discovery). O roteador que não consegue passar o pacote devolve um ICMP (Internet Control Message Protocol) do tipo "Packet Too Big", e a origem se ajusta sozinha. O Tailscale implementa isso.

Só que operadora móvel costuma filtrar esse ICMP. Resultado: o pacote é descartado e ninguém avisa ninguém. É por isso que se chama blackhole, e por isso atualizar o Tailscale não resolve este caso específico — eu já estava numa versão que tem PMTU discovery.

Como medir o teto do seu caminho

Ping com a flag de "don't fragment", variando o tamanho. O maior que passar, somado a 28 bytes de cabeçalho, é o teto real. No macOS a flag é -D; no Linux, -M do.

for s in 1100 1160 1172 1180 1252; do
  printf "%s: " $s
  ping -c 2 -t 5 -D -s $s 100.68.139.13 >/dev/null 2>&1 \
    && echo OK || echo FALHOU
done

No meu teste passou até 1172 (pacote de 1200) e falhou em 1175.

São 2 ajustes, e os dois no servidor

Corrigi no meu Rock Pi, que roda o Home Assistant e é o subnet router da casa. Isso é essencial: o iOS não permite ajustar MTU. Qualquer solução aplicada no cliente jamais atenderia o celular, que era exatamente o meu caso de uso.

O primeiro ajuste é fixar o MTU do túnel, em /etc/default/tailscaled:

TS_DEBUG_MTU=1200

Depois, systemctl restart tailscaled. Isso cobre o sentido servidor para cliente, que é onde as respostas HTTP sumiam. Sozinho, já devolveu a navegação do dia a dia. O Home Assistant abriu no celular na hora.

O segundo ajuste é o MSS clamping, que cobre o sentido inverso, onde o celular continua achando que pode mandar 1280:

iptables -t mangle -A OUTPUT  -p tcp --tcp-flags SYN,RST SYN \
  -o tailscale0 -j TCPMSS --set-mss 1160
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
  -o tailscale0 -j TCPMSS --set-mss 1160

A chain OUTPUT atende os serviços do próprio servidor e a FORWARD, os outros aparelhos da LAN que ele roteia. Usei 1160 em vez de 1200 de propósito, para ter margem quando a operadora entregar um caminho um pouco pior que o medido.

O clamping faz o servidor anunciar o limite já no handshake, então o cliente se ajusta sozinho, sem configurar nada nele. Foi o que me permitiu desfazer um ajuste manual de MTU que eu tinha feito no Mac como paliativo.

Lembrando que essas regras somem no reboot. Eu não tinha o iptables-persistent instalado e preferi não adicionar pacote: coloquei as regras num script e chamei ele por um drop-in do systemd, para nascerem junto com a interface do Tailscale.

O script vai em /usr/local/sbin/ts-mss-clamp.sh. Ele é o mesmo par de regras acima, só que percorrendo tambem o ip6tables, e apagando a regra antes de inserir — desta forma posso rodar de novo sem empilhar regra duplicada:

#!/bin/sh
MSS=1160
for BIN in iptables ip6tables; do
  for CH in OUTPUT FORWARD; do
    R="-p tcp --tcp-flags SYN,RST SYN -o tailscale0 -j TCPMSS --set-mss $MSS"
    while $BIN -w -t mangle -C $CH $R 2>/dev/null; do
      $BIN -w -t mangle -D $CH $R
    done
    $BIN -w -t mangle -A $CH $R
  done
done

Não esqueça do chmod +x. O drop-in fica em /etc/systemd/system/tailscaled.service.d/mss-clamp.conf:

[Service]
ExecStartPost=/bin/sh -c "sleep 5; /usr/local/sbin/ts-mss-clamp.sh"

Depois, systemctl daemon-reload. O sleep 5 está ali porque a tailscale0 não existe no instante em que o serviço sobe.

Apenas como lembrete: os dois ajustes ficam ativos ao mesmo tempo, e não um em vez do outro. O TS_DEBUG_MTU cuida do sentido servidor para cliente e o clamping, do cliente para servidor. Para conferir se sobreviveram a um reboot:

ip link show tailscale0 | grep -o "mtu [0-9]*"
iptables -t mangle -S | grep TCPMSS
ip6tables -t mangle -S | grep TCPMSS

Onde isso não serve

O teto de 1200 é o do meu caminho, medido num dia, numa operadora, com conexão direta. Em outra rede o número muda. Se o sintoma voltar, o certo é medir de novo, não copiar o meu valor.

O MSS clamping também vale apenas para TCP. Se algo seu trafega em UDP ou QUIC em volume, não está coberto — no meu caso, Home Assistant e acesso a arquivos são TCP, então na prática resolveu.

Por fim, o diagnóstico só andou quando percebi que o SSH falhava do mesmo jeito que o navegador. Serviço web travando admite dezenas de causas; dois protocolos sem relação falhando igual só sobra caminho de rede. Se eu tivesse reparado nisso antes, teria economizado dois anos e uma troca de operadora.

Alguém aí também trocou de operadora por um problema que não era dela?