Files
B2BCall-dialer/infrastructure/freeswitch/Dockerfile
Matheus 2134fa0aa4 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
2026-08-31 11:50:51 -03:00

197 lines
12 KiB
Docker
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# syntax=docker/dockerfile:1.7
#
# Imagem própria/controlada do FreeSWITCH (agente.md secao 19), usando os
# pacotes pré-compilados do SignalWire em vez de compilar da fonte — build
# de C/C++ e´ pesado demais pra VM de laboratorio (1.9GB RAM).
FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gnupg curl \
&& rm -rf /var/lib/apt/lists/*
# FREESWITCH_PAT só existe durante este RUN (BuildKit secret, nunca vira
# camada da imagem) e o arquivo de credenciais do apt é apagado antes do fim
# do MESMO RUN — agente.md secao 11: "a credencial nao devera permanecer na
# imagem runtime".
RUN --mount=type=secret,id=freeswitch_pat,required=true \
set -eu; \
FS_PAT="$(cat /run/secrets/freeswitch_pat)"; \
curl -fsSL -u "signalwire:${FS_PAT}" \
https://freeswitch.signalwire.com/repo/deb/debian-release/signalwire-freeswitch-repo.gpg \
-o /usr/share/keyrings/signalwire-freeswitch-repo.gpg; \
install -d -m 700 /etc/apt/auth.conf.d; \
printf 'machine freeswitch.signalwire.com\nlogin signalwire\npassword %s\n' "${FS_PAT}" \
> /etc/apt/auth.conf.d/freeswitch.conf; \
chmod 600 /etc/apt/auth.conf.d/freeswitch.conf; \
echo "deb [signed-by=/usr/share/keyrings/signalwire-freeswitch-repo.gpg] https://freeswitch.signalwire.com/repo/deb/debian-release/ trixie main" \
> /etc/apt/sources.list.d/freeswitch.list; \
apt-get update; \
apt-get install -y --no-install-recommends \
freeswitch-meta-vanilla \
freeswitch-conf-vanilla \
freeswitch-mod-xml-curl \
freeswitch-mod-callcenter \
freeswitch-mod-avmd \
freeswitch-mod-curl; \
rm -f /etc/apt/auth.conf.d/freeswitch.conf /etc/apt/sources.list.d/freeswitch.list; \
rm -rf /var/lib/apt/lists/*
# Overrides mínimos por cima da config vanilla (agente.md secao 15: "carregar
# somente o necessário"). Directory/dialplan/sip_profiles ficam na config
# vanilla padrão por enquanto — serão substituídos por mod_xml_curl na fase
# "Extensions/Trunks/Dialplan" (ver docs/FREESWITCH.md).
COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_configs/modules.conf.xml
COPY overrides/autoload_configs/event_socket.conf.xml /etc/freeswitch/autoload_configs/event_socket.conf.xml
COPY overrides/autoload_configs/acl.conf.xml /etc/freeswitch/autoload_configs/acl.conf.xml
COPY overrides/autoload_configs/xml_curl.conf.xml /etc/freeswitch/autoload_configs/xml_curl.conf.xml
COPY overrides/autoload_configs/callcenter.conf.xml /etc/freeswitch/autoload_configs/callcenter.conf.xml
RUN mkdir -p /etc/freeswitch/autoload_configs/callcenter_queues.conf.d
# Pino $${domain} num valor estavel em vez do IP dinamico do container
# (vars.xml vanilla usa "domain=$${local_ip_v4}", que muda a cada restart e
# nunca bateria com Tenant.telephonyDomain). Ver docs/EXTENSIONS.md.
ARG DEFAULT_SIP_DOMAIN=b2bcall.local
RUN sed -i "s/data=\"domain=\$\${local_ip_v4}\"/data=\"domain=${DEFAULT_SIP_DOMAIN}\"/" \
/etc/freeswitch/vars.xml \
&& grep -q "domain=${DEFAULT_SIP_DOMAIN}" /etc/freeswitch/vars.xml
# Multi-domínio real por tenant (PHASE 52, docs/EXTENSIONS.md) — achado
# real, confirmado só depois de registrar um SIP de verdade (o teste por
# curl direto no mod_xml_curl não pegava isto, porque roda ANTES do
# xml_curl ser consultado): o profile "internal" vanilla vem com
# `force-register-domain`/`force-subscription-domain`/
# `force-register-db-domain` fixados em `$${domain}` — REGISTER de
# QUALQUER identidade era resolvido contra o domínio fixo do profile
# ("b2bcall.local"), nunca o domínio que o tenant realmente tem
# (`acme.b2bcall.net`), dando 403 Forbidden sempre. `<domain name="all"
# alias="true".../>` (também vanilla) só afeta contexto de dialplan, não
# esta checagem de REGISTER. Removendo os 3 params (procedimento padrão
# documentado do próprio FreeSWITCH pra multi-domínio), o REGISTER passa
# a resolver o domínio do próprio pedido — confirmado registrando um
# softphone de teste de verdade contra `acme.b2bcall.net`.
RUN sed -i \
-e '/<param name="force-register-domain"/d' \
-e '/<param name="force-subscription-domain"/d' \
-e '/<param name="force-register-db-domain"/d' \
/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
# Docker pela primeira vez (nenhuma porta SIP/RTP estava publicada até
# aqui — só o Event Socket interno). Restrito a 200 portas, suficiente
# pra alguns testes/chamadas simultâneas em laboratório.
RUN sed -i \
-e 's#<!-- <param name="rtp-start-port" value="16384"/> -->#<param name="rtp-start-port" value="16384"/>#' \
-e 's#<!-- <param name="rtp-end-port" value="32768"/> -->#<param name="rtp-end-port" value="16584"/>#' \
/etc/freeswitch/autoload_configs/switch.conf.xml \
&& grep -q '<param name="rtp-start-port" value="16384"/>' /etc/freeswitch/autoload_configs/switch.conf.xml \
&& grep -q '<param name="rtp-end-port" value="16584"/>' /etc/freeswitch/autoload_configs/switch.conf.xml
# Rotas de entrada por DID (PHASE 56, docs/INBOUND_ROUTES.md) — achado
# real: nenhuma chamada que chega pelo profile "external" carrega
# `b2bcall_tenant_id` (só REGISTER de ramal e discagem de saída setam essa
# variable), então uma chamada de entrada não tem como saber de qual
# tenant é. O contexto "public" vanilla (pra onde o profile "external"
# aponta por padrão) é um ARQUIVO ESTÁTICO (`dialplan/public.xml`) — a
# config estática sempre ganha da consulta ao mod_xml_curl, então esse
# contexto NUNCA seria dinâmico enquanto se chamar "public". Renomeado
# pra "inbound" (sem arquivo estático nenhum): toda chamada de entrada
# passa a cair no mod_xml_curl (b2bcall-fs-config), que resolve o dono do
# DID e injeta o tenant antes de encaminhar (packages/telephony
# buildInboundRouteXml). `dialplan/public.xml` fica no lugar, sem efeito
# — não precisa remover, só não é mais alcançado por nenhum profile.
RUN sed -i 's#<param name="context" value="public"/>#<param name="context" value="inbound"/>#' \
/etc/freeswitch/sip_profiles/external.xml \
&& grep -q '<param name="context" value="inbound"/>' /etc/freeswitch/sip_profiles/external.xml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8021
EXPOSE 5060/udp 5060/tcp
EXPOSE 16384-16584/udp
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/bin/freeswitch", "-nonat"]