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

@@ -49,11 +49,18 @@ services:
FS_CONFIG_USER: ${FS_CONFIG_USER}
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
ESL_HOST: freeswitch
# host.docker.internal (PHASE 68): freeswitch virou network_mode:
# host, saiu da rede bridge do compose — o nome de serviço
# "freeswitch" não resolve mais pra ele. host.docker.internal
# aponta pro HOST (onde o ESL agora escuta direto), exige o
# extra_hosts abaixo em Linux (Docker 20.10+).
ESL_HOST: host.docker.internal
ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD}
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
CALLCENTER_QUEUES_DIR: /callcenter-queues
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
# escreve os XML de gateway aqui, o profile "external" ja inclui
@@ -63,7 +70,12 @@ services:
# escreve um XML de fila por arquivo aqui, incluido via
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
- freeswitch_callcenter_queues:/callcenter-queues
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
# Publicado só em loopback (PHASE 68): o FreeSWITCH virou
# network_mode: host e chama isto via 127.0.0.1 agora (ver
# xml_curl.conf.xml) — antes não precisava de porta nenhuma porque os
# dois estavam na mesma rede bridge do compose.
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
@@ -99,28 +111,31 @@ services:
# uma chamada ativa seria latência/confiabilidade desnecessárias
# pra um prompt de poucos segundos.
- ./data/ivr-prompts:/ivr-prompts
# Event Socket (8021) NUNCA publicado pra REDE — só em loopback do
# próprio host (agente.md secao 22: "NÃO pública... acesso somente
# pela aplicação autorizada"). Achado real testando as telas de
# Infraestrutura (platform): o comentário original aqui dizia que
# elas SEMPRE falhariam nesta VM porque "apps/api roda fora do
# Docker" — isso estava incompleto. `apps/api` (systemd, no host)
# JÁ conseguia alcançar o IP do container na rede bridge sem nenhuma
# porta publicada (`docker inspect` + `/dev/tcp` confirmam: o HOST
# sempre alcança a rede bridge do Docker, só outras MÁQUINAS é que
# não). O bloqueio real era `ESL_HOST=freeswitch` — um nome DNS que
# só existe dentro da rede interna do Docker, nunca no resolver do
# host. `127.0.0.1:8021:8021` resolve isso sem abrir a porta pra
# ninguém além do próprio host — mesma garantia de segurança de
# antes, só que a aplicação autorizada agora alcança de verdade.
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
# pra rede a partir da PHASE 54 — necessário pra registrar um
# softphone/telefone de fora da rede Docker.
ports:
- "127.0.0.1:8021:8021"
- "5060:5060/udp"
- "5060:5060/tcp"
- "16384-16584:16384-16584/udp"
# network_mode: host (PHASE 68) — achado real reportado pelo usuário:
# dois ramais reais (telefones IP), atrás do MESMO roteador/NAT desta
# rede (esta VM nunca tem IP público próprio — é sempre RFC1918 por
# trás do NAT do escritório), ligando um pro outro. Com bridge+NAT do
# Docker no meio, o SIP/RTP entre eles precisava atravessar uma camada
# de tradução de endereço A MAIS antes mesmo de chegar no roteador do
# escritório — combinado com o hairpin NAT do roteador (dois clientes
# atrás do mesmo NAT se enxergando só pelo IP público), a chamada
# falhava em 0ms (503, nunca chegava a sair pela rede de verdade).
# Tirar o Docker NAT do meio (host networking) elimina essa camada
# extra; o fix real de hairpin em si é separado — ver comentário sobre
# `NDLB-received-in-nat-reg-contact` no Dockerfile.
#
# Sem `ports:` aqui (inválido com network_mode: host — o container usa
# as portas do HOST diretamente). Segurança do Event Socket (8021,
# agente.md secao 22) deixa de depender de publicar só em loopback
# (não existe mais essa camada) e passa a depender inteiramente da
# ACL `apply-inbound-acl="b2bcall_internal"` já configurada em
# event_socket.conf.xml (127.0.0.0/8 + 172.16.0.0/12, o range do
# bridge do Docker) — qualquer outra origem continua rejeitada mesmo
# com a porta agora "aberta" nas interfaces do host. Os workers que
# antes alcançavam via nome de serviço `freeswitch` na rede do compose
# (fs-events/fs-config/predictive-dialer) precisam agora de
# `host.docker.internal` — ver `extra_hosts` em cada um deles abaixo.
network_mode: host
healthcheck:
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
interval: 10s
@@ -139,7 +154,9 @@ services:
- redis
- postgres
environment:
ESL_HOST: freeswitch
# host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
# equivalente em fs-config acima.
ESL_HOST: host.docker.internal
ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD}
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
@@ -159,6 +176,8 @@ services:
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./data/recordings-spool:/recordings
- ./data/object-storage-local:/data/object-storage
@@ -174,7 +193,9 @@ services:
- redis
- postgres
environment:
ESL_HOST: freeswitch
# host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
# equivalente em fs-config acima.
ESL_HOST: host.docker.internal
ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD}
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
@@ -188,6 +209,8 @@ services:
# — quem de fato grava e' o processo FreeSWITCH, não este worker;
# não precisa do volume montado aqui, só saber o path Docker-interno.
RECORDINGS_SPOOL_DIR: /recordings
extra_hosts:
- "host.docker.internal:host-gateway"
ai-worker:
build: