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

68
TODO.md
View File

@@ -2456,6 +2456,74 @@ rota de entrada")
os dois persistiram com o tipo certo, sobrevivendo a um reload os dois persistiram com o tipo certo, sobrevivendo a um reload
completo da página. completo da página.
## PHASE 68 — Ramal registrado mas chamada não completava / sem áudio
(reportado pelo usuário testando com 2 telefones IP reais, 1502 e 1503)
- [x] **Achado 1 (chamada não completava, 503 instantâneo)**: os dois
ramais registravam com o Contact apontando pro MESMO IP público
compartilhado desta rede (esta VM nunca tem IP público próprio —
é sempre RFC1918 atrás do NAT do escritório). Ao originar uma
chamada, o FreeSWITCH tentava mandar o INVITE de volta pra esse
IP público — hairpin NAT clássico, o roteador do escritório não
sabe refletir esse tráfego de volta pra dentro. Corrigido com
`NDLB-received-in-nat-reg-contact` (Dockerfile) — o Contact salvo
passa a ser o IP/porta REALMENTE observados no pacote (a rede
interna), não o que o telefone auto-relata.
- [x] `network_mode: host` no serviço `freeswitch` (pedido do usuário,
"sempre é problema NAT do Docker") — tira a camada de NAT/bridge
do Docker do meio. Quebra em cascata corrigida: FreeSWITCH sai da
rede bridge do compose, então (a) não resolve mais os OUTROS
containers pelo nome de serviço — `fs-config` virou
`http://127.0.0.1:8080/` (porta publicada só em loopback,
xml_curl.conf.xml) —, e (b) os workers que chamavam ESL via nome
`freeswitch` (fs-events/fs-config/predictive-dialer) passam a usar
`host.docker.internal` (+ `extra_hosts: host-gateway`, Docker
20.10+). Segurança do Event Socket deixa de depender de publicar
só em loopback (não existe mais essa camada) e passa a depender
inteiramente da ACL `b2bcall_internal` já configurada
(`apply-inbound-acl`) — confirmado que continua rejeitando
qualquer origem fora de loopback/rede do Docker.
- [x] **Achado 2 (chamada completava, mas sem áudio)**: com o registro
corrigido, a ligação passou a completar, mas sem RTP (confirmado
pelo usuário com `sngrep`). `local-network-acl="localnet.auto"`
(padrão 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 de novo (mesmo
hairpin, agora na mídia). Corrigido com uma ACL própria
(`b2bcall_lan`, cobre todo RFC1918 — nunca alcançável de verdade
"de fora" nesta VM) referenciada em `local-network-acl`.
- [x] **Achado 3 (chamada completava e tinha áudio, mas morria sozinha
em exatos 32s)**: mesmo com áudio já corrigido, toda chamada
morria sozinha depois de exatos 32 segundos — o Timer H do
RFC 3261 (64×T1, prazo padrão de qualquer UAS esperando o ACK de
um 2xx). Só descoberto de verdade com captura de pacote real
(`tcpdump`, instalado nesta sessão — não estava no host): o
`200 OK` que o FreeSWITCH manda pro ramal que RECEBEU a chamada
ainda tinha `Contact: sip:...@191.240.175.55:5060` (o IP público
de novo, MESMO com o SDP já mostrando o IP certo) — o telefone
nunca manda o ACK de volta (não tem como chegar por ali), o
FreeSWITCH retransmite o 200 OK sozinho (0.5s/1s/2s/4s/4s...) até
desistir e derrubar a chamada. `apply-nat-acl`/`local-network-acl`
(achados 1/2) não bastam aqui — são checagens independentes, e
trocar `apply-nat-acl` pra escopo `b2bcall_lan` (testado) não
mudou nada nesse Contact. O fix de verdade: `ext-rtp-ip`/
`ext-sip-ip` (STUN, resolvem pro IP público) REMOVIDOS do profile
`internal` (Dockerfile) — sem endereço "externo" nenhum
configurado, o FreeSWITCH não tem mais como escolher errado,
cai sempre em `rtp-ip`/`sip-ip` (10.10.32.138). Único profile
afetado; `external` (reservado pra um trunk PSTN real, nunca
usado ainda) mantém os dois params intactos.
- [x] Diagnosticado ao vivo lendo `/var/log/freeswitch/freeswitch.log`
em tempo real durante as tentativas de chamada do usuário (nunca
supondo causa sem ver o log) e, pro achado 3, uma captura de
pacote real via `tcpdump` — cada achado confirmado por evidência
exata (`NORMAL_TEMPORARY_FAILURE`/503 instantâneo pro achado 1;
endereço do `c=`/RTP-IP pro achado 2; cabeçalho `Contact:` do
`200 OK` capturado byte a byte pro achado 3). Confirmado resolvido
pelo usuário com chamadas reais nos dois sentidos (1502→1503 e
1503→1502), áudio bidirecional e chamada sobrevivendo bem além
dos 32s que travavam toda tentativa antes do achado 3.
--- ---
## Riscos conhecidos ## Riscos conhecidos

View File

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

View File

@@ -1,31 +1,47 @@
# Arquitetura de Rede # Arquitetura de Rede
## Estado atual (fase FreeSWITCH inicial) ## Estado atual (PHASE 68 — `network_mode: host`)
`freeswitch` roda na rede padrão do Docker Compose (bridge, `b2bcall_default`), `freeswitch` roda em `network_mode: host` — decisão tomada na PHASE 68 (era
igual a `postgres` e `redis`. Nenhuma porta é publicada no host — nem 8021 "pendente" antes, ver seção abaixo), pedida pelo usuário depois de dois
(ESL), nem SIP (5060/5080), nem RTP. Isso é intencional: ainda não existe ramais reais no mesmo escritório não conseguirem se ligar. `postgres`/
nenhum tronco SIP real nem ramal externo, então não há motivo pra expor nada. `redis`/`fs-config` continuam na rede bridge padrão do compose
(`b2bcall_default`); só o FreeSWITCH saiu dela.
`apps/api` roda hoje **direto no host** (fora do Docker), então usa Com host networking, o FreeSWITCH usa a stack de rede do PRÓPRIO host
`APP_DATABASE_URL`/`REDIS_URL` apontando pra `localhost` nas portas publicadas diretamente — sem NAT/bridge do Docker no meio, `local_ip_v4` resolve pro IP
pelo Postgres/Redis. Ela não consegue (nem precisa, ainda) alcançar o real da interface (`10.10.32.138` nesta VM), SIP/ESL escutam direto nas
FreeSWITCH. interfaces do host. Isso elimina uma camada de tradução de endereço que
existia antes de qualquer roteador físico entrar em cena — ver PHASE 68 no
TODO.md pro relato completo dos 3 achados (registro, áudio, e a chamada
morrendo sozinha em 32s) que motivaram e validaram essa mudança.
## Decisão pendente: `network_mode` do FreeSWITCH (agente.md secao 19) Efeitos em cascata (o FreeSWITCH sai da rede bridge do compose):
- Não resolve mais os outros containers pelo nome de serviço — `fs-config`
agora é alcançado via `http://127.0.0.1:8080/` (porta publicada só em
loopback, `xml_curl.conf.xml`).
- Os workers que chamavam ESL via nome `freeswitch`
(fs-events/fs-config/predictive-dialer) usam `host.docker.internal` (+
`extra_hosts: host-gateway`, exige Docker 20.10+).
- Segurança do Event Socket deixa de depender de "publicar só em loopback"
(não existe mais essa camada) e passa a depender inteiramente da ACL
`b2bcall_internal` já configurada (`apply-inbound-acl`) — confirmado que
continua rejeitando qualquer origem fora de loopback/rede do Docker.
Quando existir um tronco SIP real (fase Trunks), será preciso decidir entre: `apps/api` continua rodando **direto no host** (fora do Docker) — nada muda
pra ela, `ESL_HOST=127.0.0.1` já funcionava e continua funcionando (agora de
forma ainda mais direta, sem a camada de publish-port do Docker no meio).
- **`network_mode: host`**: mais simples pra SIP/RTP (sem NAT entre o ## Decisão tomada: `network_mode` do FreeSWITCH (agente.md secao 19)
container e a rede), mas perde isolamento de rede do Docker.
- **macvlan/ipvlan**: dá ao FreeSWITCH um IP próprio na rede física, sem expor
outros serviços do host: mais trabalho de configurar, melhor isolamento.
Não decidido ainda — só vira relevante quando houver um carrier/SBC real pra Historicamente listada como pendente (macvlan/ipvlan vs. host), resolvida na
conectar (secao 17: "FreeSWITCH não deverá depender de IP SIP público" — a PHASE 68 a favor de **`network_mode: host`** — não por causa de um trunk
topologia esperada é `Internet → OpenSIPS → rede SIP privada → FreeSWITCH`, PSTN real (a topologia `Internet → OpenSIPS → rede SIP privada →
então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece FreeSWITCH` da secao 17 continua valendo pra isso), mas por um problema
manter bridge/macvlan em vez de host). concreto de NAT entre dois ramais registrados no mesmo escritório (ver
achado abaixo). `macvlan`/`ipvlan` continuam como alternativas válidas se um
dia o isolamento de rede do host virar prioridade maior que a simplicidade
atual — não implementadas, não descartadas.
## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio ## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio

View File

@@ -78,6 +78,83 @@ RUN sed -i \
/etc/freeswitch/sip_profiles/internal.xml \ /etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /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 # 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 # 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 # 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="::1/128"/>
<node type="allow" cidr="172.16.0.0/12"/> <node type="allow" cidr="172.16.0.0/12"/>
</list> </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> </network-lists>
</configuration> </configuration>

View File

@@ -6,7 +6,13 @@
vanilla continua valendo como fallback quando "not found". vanilla continua valendo como fallback quando "not found".
Credenciais substituidas em runtime pelo entrypoint.sh — nunca Credenciais substituidas em runtime pelo entrypoint.sh — nunca
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). --> 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="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
<param name="auth-scheme" value="basic"/> <param name="auth-scheme" value="basic"/>
<param name="timeout" value="5"/> <param name="timeout" value="5"/>