fix(freeswitch): 3 achados reais de NAT entre ramais no mesmo escritório — registro, áudio e chamada morrendo em 32s (PHASE 68)

Usuário reportou que 1502 ligando pra 1503 (dois telefones IP reais,
ambos registrados) não completava a chamada. Três bugs em camadas
diferentes, cada um só confirmado lendo o log/capturando pacote real —
nunca por suposição:

1. Registro: os dois ramais registravam com Contact apontando pro MESMO
   IP público compartilhado desta rede (a VM nunca tem IP público
   próprio, é sempre RFC1918 atrás do NAT do escritório) — originar uma
   chamada tentava mandar o INVITE de volta pra esse IP público, hairpin
   NAT clássico, falha instantânea (503). Fix: NDLB-received-in-nat-reg-
   contact (Contact salvo vira o IP realmente observado no pacote).

   De quebra, aplicado network_mode: host no serviço freeswitch (pedido
   explícito do usuário) — tira o Docker NAT/bridge do meio. Quebra em
   cascata corrigida: fs-config vira alcançável só via 127.0.0.1:8080
   (não mais nome de serviço), workers ESL (fs-events/predictive-dialer)
   passam a usar host.docker.internal.

2. Áudio: com o registro corrigido, a chamada completava mas sem RTP —
   local-network-acl="localnet.auto" só cobria a subnet da própria
   interface do FreeSWITCH, tratando ramais de OUTRAS subnets do mesmo
   escritório como "de fora" e trocando o SDP pelo IP público de novo.
   Fix: ACL própria (b2bcall_lan, cobre todo RFC1918) referenciada em
   local-network-acl.

3. Chamada morrendo sozinha em exatos 32s (Timer H do RFC 3261): mesmo
   com áudio ok, o 200 OK que o FreeSWITCH manda pro ramal que recebeu a
   chamada ainda tinha Contact com o IP público — o telefone nunca manda
   o ACK de volta, FreeSWITCH retransmite sozinho até desistir. Só
   confirmado com tcpdump (instalado nesta sessão) capturando o pacote
   byte a byte. Fix: ext-rtp-ip/ext-sip-ip (STUN, sempre resolvem pro IP
   público) removidos do profile "internal" — sem endereço "externo"
   configurado, o FreeSWITCH nunca mais tem como escolher errado.

Confirmado resolvido pelo usuário com chamadas reais nos dois sentidos,
áudio bidirecional, sobrevivendo bem além dos 32s que travavam antes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-31 11:50:51 -03:00
parent 432cd55adb
commit 2134fa0aa4
6 changed files with 290 additions and 47 deletions

View File

@@ -78,6 +78,83 @@ RUN sed -i \
/etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
# Hairpin NAT entre dois ramais atrás do MESMO roteador (PHASE 68, achado
# real reportado pelo usuário: "1502 liga pra 1503 e não completa" —
# ambos registrados, mas a chamada falhava em 0ms com 503). Confirmado
# lendo `sofia status profile internal reg`: os dois ramais registram com
# Contact apontando pro MESMO IP público desta rede (a VM nunca tem IP
# público próprio — secao "essa máquina nunca terá IP público" — telefone
# e servidor share o mesmo NAT do escritório). `apply-nat-acl` (nat.auto,
# já testado funcionando) SÓ detecta que o cliente está atrás de NAT; sem
# `NDLB-received-in-nat-reg-contact`, o Contact salvo continua sendo o que
# o telefone auto-relatou (o IP público da rede) em vez do IP realmente
# observado no pacote UDP (a rede interna, ex.: 192.168.40.165) — originar
# uma chamada nova pro Contact salvo então tenta mandar o INVITE de volta
# pro IP público, que o roteador do escritório não sabe refletir de volta
# pra dentro (hairpin NAT clássico). Ligando este parâmetro, o Contact
# salvo passa a ser o IP/porta REALMENTE observados — a chamada entre dois
# ramais no mesmo escritório passa a rotear direto pela rede interna,
# nunca saindo (e voltando) pelo roteador.
RUN sed -i \
-e 's#<!-- *<param name="NDLB-received-in-nat-reg-contact" value="true"/> *-->#<param name="NDLB-received-in-nat-reg-contact" value="true"/>#' \
/etc/freeswitch/sip_profiles/internal.xml \
&& grep -q '<param name="NDLB-received-in-nat-reg-contact" value="true"/>' /etc/freeswitch/sip_profiles/internal.xml
# Continuação do achado acima: registro corrigido, mas a CHAMADA
# completava sem áudio. `local-network-acl="localnet.auto"` (vanilla) só
# cobre a subnet da própria interface do FreeSWITCH — os ramais reais
# ficam em OUTRAS subnets do mesmo escritório, então eram tratados como
# "de fora" e o SDP trocava o RTP/SIP anunciado pelo IP público
# compartilhado (mesmo hairpin de antes, agora na mídia). `b2bcall_lan`
# (acl.conf.xml, cobre todo RFC1918 — nunca alcançável de verdade "de
# fora" nesta VM, que nunca tem IP público próprio) resolve isso.
RUN sed -i \
-e 's#<param name="local-network-acl" value="localnet.auto"/>#<param name="local-network-acl" value="b2bcall_lan"/>#' \
/etc/freeswitch/sip_profiles/internal.xml \
&& grep -q '<param name="local-network-acl" value="b2bcall_lan"/>' /etc/freeswitch/sip_profiles/internal.xml
# Terceira camada do mesmo achado: com áudio corrigido, a chamada ainda
# morria sozinha em exatos 32s (Timer H do RFC 3261) — o 200 OK que o
# FreeSWITCH manda pro ramal que RECEBEU a chamada tinha
# `Contact: sip:...@191.240.175.55:5060` (IP público de novo), então o
# telefone nunca manda o ACK de volta (não tem como voltar por ali) e o
# FreeSWITCH fica retransmitindo o 200 OK sozinho até desistir. Achado
# via captura de pacote real (tcpdump), não suposição.
#
# `b2bcall_external_nat` (acl.conf.xml, sentido INVERTIDO de
# `b2bcall_lan`: só marca como NAT'd quem está de verdade fora de todo
# RFC1918 conhecido) escopa `apply-nat-acl` corretamente pra esta
# implantação — mantido por ser mais correto e não quebrar o fix de
# registro acima (testado: `NDLB-received-in-nat-reg-contact` não
# depende de `apply-nat-acl` bater, continua funcionando mesmo sem o
# `fs_nat=yes` marcado). MAS isto sozinho não resolveu o Contact do
# 200 OK — confirmado com uma segunda captura de pacote depois desta
# mudança, o Contact continuou com o IP público.
RUN sed -i \
-e 's#<param name="apply-nat-acl" value="nat.auto"/>#<param name="apply-nat-acl" value="b2bcall_external_nat"/>#' \
/etc/freeswitch/sip_profiles/internal.xml \
&& grep -q '<param name="apply-nat-acl" value="b2bcall_external_nat"/>' /etc/freeswitch/sip_profiles/internal.xml
# O fix de verdade pro Contact: `ext-rtp-ip`/`ext-sip-ip` (STUN,
# `$${external_sip_ip}` = 191.240.175.55) fazem FreeSWITCH sempre ter
# um endereço "público" pronto pra usar como identidade própria em
# respostas SIP — independente de qualquer ACL de NAT, é só o valor
# que o profile usa quando "se apresentando" externamente. Esta VM
# NUNCA tem IP público próprio de verdade (confirmado pelo usuário) —
# pro profile "internal" (só ramais registrados deste escritório,
# nunca um trunk/SBC de verdade), esse endereço "externo" nunca é
# correto, só existe pra causar exatamente este bug. Removendo os 2
# params, o profile cai automaticamente pra `rtp-ip`/`sip-ip`
# (10.10.32.138) pra tudo, sem exceção — não tem mais endereço "errado"
# pro FreeSWITCH escolher. O profile "external" (reservado pra um
# trunk PSTN real, nunca usado ainda nesta implantação) mantém
# `ext-rtp-ip`/`ext-sip-ip` intactos — só o "internal" precisava disso.
RUN sed -i \
-e '/<param name="ext-rtp-ip" value="\$\${external_rtp_ip}"\/>/d' \
-e '/<param name="ext-sip-ip" value="\$\${external_sip_ip}"\/>/d' \
/etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q 'ext-rtp-ip\|ext-sip-ip' /etc/freeswitch/sip_profiles/internal.xml
# RTP num range fixo e pequeno (PHASE 54, docs/EXTENSIONS.md) — o range
# vanilla default (16384-32768, ~16k portas) é inviável de publicar uma a
# uma no host; achado ao tentar registrar um softphone de fora da rede

View File

@@ -10,5 +10,58 @@
<node type="allow" cidr="::1/128"/>
<node type="allow" cidr="172.16.0.0/12"/>
</list>
<!-- PHASE 68 — achado real: depois de corrigir o registro (NDLB-
received-in-nat-reg-contact, Dockerfile), a chamada completava
mas sem áudio. Causa: `local-network-acl="localnet.auto"`
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
FreeSWITCH (10.10.32.0/24, com network_mode: host) — os ramais
de verdade estão em OUTRAS subnets do mesmo escritório
(192.168.40.0/24, 192.168.44.0/24), então o FreeSWITCH os
tratava como "fora da rede local" e trocava o RTP/SIP anunciado
no SDP pelo IP público compartilhado (Ext-RTP-IP/Ext-SIP-IP,
191.240.175.55) — o mesmo hairpin NAT de antes, só que na
mídia em vez do registro. Esta VM nunca tem IP público próprio
(é sempre RFC1918 atrás do NAT do escritório), então qualquer
cliente que realmente CONSEGUE alcançar este servidor já é,
por definição, parte dessa rede privada — cobrir todo o RFC1918
aqui é seguro pra esta implantação (nunca é "de fora" de
verdade) e evita ter que listar cada subnet/VLAN do escritório
uma por uma. -->
<list name="b2bcall_lan" default="deny">
<node type="allow" cidr="10.0.0.0/8"/>
<node type="allow" cidr="172.16.0.0/12"/>
<node type="allow" cidr="192.168.0.0/16"/>
</list>
<!-- PHASE 68 (continuação) — achado real: com áudio já corrigido, o
200 OK que o FreeSWITCH manda pra um ramal como 1503 ainda tinha
`Contact: sip:1502@191.240.175.55:5060` (o IP público de novo) —
o telefone nunca manda o ACK de volta (não é dele, não sabe
voltar), o FreeSWITCH fica retransmitindo o 200 OK sozinho
(0.5s/1s/2s/4s/4s...) até desistir em exatos 32s (Timer H do
RFC 3261) e derrubar a chamada. Causa: o Contact que o
FreeSWITCH usa pra SE IDENTIFICAR numa resposta SIP é decidido
por `apply-nat-acl` (nat.auto), não por `local-network-acl` —
são duas checagens INDEPENDENTES (uma pro SDP/mídia, outra pro
cabeçalho SIP). `nat.auto` (RFC1918 exceto a própria subnet do
FreeSWITCH) continua marcando ramais de OUTRAS subnets do
escritório como "precisa de IP externo" nesta camada, mesmo já
não afetando mais o SDP. Não dá pra simplesmente trocar
`apply-nat-acl` pra `b2bcall_lan` (mesmo sentido de match de
`local-network-acl`) — isso pararia de marcar esses ramais como
NAT'd, e SEM essa marcação o `NDLB-received-in-nat-reg-contact`
(Dockerfile, o fix do REGISTRO) para de disparar, voltando o
bug original. Por isso esta é uma ACL NOVA e SEPARADA, com o
sentido INVERTIDO de `b2bcall_lan`: "NAT'd de verdade" só quem
está FORA de todo RFC1918 conhecido desta implantação — nunca
alcançável de verdade nesta VM (nunca tem IP público próprio),
mas mantém o comportamento correto se um dia existir uma
conexão genuinamente externa (trunk real via IP público). -->
<list name="b2bcall_external_nat" default="allow">
<node type="deny" cidr="10.0.0.0/8"/>
<node type="deny" cidr="172.16.0.0/12"/>
<node type="deny" cidr="192.168.0.0/16"/>
</list>
</network-lists>
</configuration>

View File

@@ -6,7 +6,13 @@
vanilla continua valendo como fallback quando "not found".
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
<param name="gateway-url" value="http://fs-config:8080/" bindings="directory|dialplan"/>
<!-- 127.0.0.1, nao "fs-config" (PHASE 68): com o FreeSWITCH em
network_mode: host, ele nao esta mais na rede bridge do
compose, entao o DNS embutido do Docker (nomes de servico) nao
resolve mais pra ele. fs-config publica 8080 so em loopback do
host (docker-compose.yml) — o FreeSWITCH, agora usando a
propria stack de rede do host, alcanca por ali. -->
<param name="gateway-url" value="http://127.0.0.1:8080/" bindings="directory|dialplan"/>
<param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
<param name="auth-scheme" value="basic"/>
<param name="timeout" value="5"/>