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
- extensions table (tenant-scoped, RLS): number, sip_password_enc
(AES-256-GCM via packages/shared/src/crypto.ts), caller_id, context,
sofia_profile, codecs, max_registrations
- apps/api/src/extensions: CRUD (POST/GET/GET:id/DELETE), protected by a
new generic PermissionGuard (@RequirePermission decorator), tenant
resolved only from the JWT (never trusted from the client)
- SIP password is returned in plaintext only once, in the create response;
toPublicExtension() explicitly destructures the encrypted field out
(not a spread) so it can't leak by accident
- b2bcall-fs-config now resolves real directory data: Tenant.telephonyDomain
-> Extension.number, decrypts the password, builds proper directory XML
including a dial-string param (missing it caused originate to fail with
MANDATORY_IE_MISSING instead of the expected USER_NOT_REGISTERED)
- pinned FreeSWITCH's 357737{domain} to a stable value (b2bcall.local) via a
vars.xml patch in the Dockerfile -- it previously used the container's
dynamic IP, which could never match a stored telephony_domain
- added HTTP Basic auth between FreeSWITCH and fs-config
(gateway-credentials, timingSafeEqual comparison) now that the service
returns real secret data, closing the gap flagged as pending in the XML
Curl phase instead of leaving it open
- found and fixed: PermissionGuard's constructor-injected Reflector came
back undefined at runtime under tsx/esbuild (unreliable cross-file
decorator metadata emission) -- fixed with an explicit @Inject(Reflector);
worth watching for in future guards/services run via tsx
- verified end-to-end: create extension -> originate user/<ext> reports
USER_NOT_REGISTERED (found, not registered) -> delete -> back to
SUBSCRIBER_ABSENT (not found); password never reappears in any GET;
unauthenticated fs-config requests get 401
- docs/EXTENSIONS.md
- apps/freeswitch-config (b2bcall-fs-config): Fastify service implementing
the mod_xml_curl HTTP protocol (form-encoded POST -> XML response),
containerized, no host port published
- reactivated mod_xml_curl in FreeSWITCH, binding restricted to
directory|dialplan only (configuration was removed after testing showed
it firing several unnecessary HTTP round-trips at boot for module
configs we don't need dynamic — matches agente.md's own 'don't put every
critical config through XML Curl' guidance)
- no extensions/dialplan tables exist yet (next phases), so the service
always answers 'not found' for now — this phase only proves the wire
protocol works without breaking the static vanilla config fallback
- verified end-to-end: user/8888 (nowhere) -> SUBSCRIBER_ABSENT via
fs-config; user/1000 (static vanilla extension) -> USER_NOT_REGISTERED,
proving FreeSWITCH correctly falls through to static XML when xml_curl
says not found
- docs/XML_CURL.md, including the not-yet-authenticated endpoint note (fine
while it only returns not-found; needs gateway-credentials before serving
real directory/dialplan data)