Files
B2BCall-dialer/docs/TRUNKS.md
Matheus 4c638ad496 feat: implement Trunks with real FreeSWITCH gateway sync
- trunks table (tenant-scoped, RLS): host/proxy/realm, register,
  username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, and a
  status/status_updated_at pair meant to be driven by FreeSWITCH events
- apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), same RBAC/tenant
  pattern as Extensions, password never exposed in any GET
- packages/telephony: buildGatewayXml() generates a Sofia gateway XML file
- b2bcall-fs-config now writes sip_profiles/external/<trunk_id>.xml (shared
  Docker volume with FreeSWITCH -- the vanilla external profile already
  includes external/*.xml) and runs 'sofia profile external rescan' over
  ESL; syncs on boot and on demand via Redis pub/sub
  (b2bcall:trunks:sync), since apps/api runs on the host and fs-config has
  no port published to reach directly
- added FreeSwitchTelephonyProvider.waitUntilConnected() to fix a startup
  race: the first sync ran before the ESL connection had settled, logging
  a harmless but noisy error
- verified end-to-end with a fake host: create trunk -> gateway file
  written -> FreeSWITCH shows the real gateway (FAIL_WAIT, expected) ->
  delete -> file removed (cleanup also correctly swept the stale
  'example.com' gateway that had been copied into the volume from the
  vanilla image)
- apps/freeswitch-events/src/trunk-status.ts: written to update Trunk.status
  from sofia::gateway_state events, using the same normalizeEslEvent path
  already proven for CHANNEL_* events

- KNOWN GAP, documented rather than glossed over: monitored fs-events for
  ~90s while the gateway visibly transitioned states in FreeSWITCH
  (FAIL_WAIT/DOWN) and no sofia::gateway_state event was observed arriving.
  CUSTOM/sofia::* events have not actually been proven working end-to-end
  in this session -- only CHANNEL_* events have been. Needs verification
  against a real SIP target before the status auto-update can be trusted
  in production. See docs/TRUNKS.md and TODO.md.

- docs/TRUNKS.md
2026-08-28 08:01:56 -03:00

4.3 KiB

Trunks (Troncos SIP)

Agente.md seções 41-42. Primeiro recurso que grava configuração real no filesystem do FreeSWITCH (gateways Sofia) em vez de só responder via mod_xml_curl.

Modelo

trunks (tenant-scoped, RLS): host, proxy, realm, register, username/password_enc (AES-256-GCM, mesmo padrão de sip_password_enc), from_user/from_domain, register_proxy/outbound_proxy, expire_seconds, retry_seconds, dtmf_mode, ping/ping_frequency, transport, max_cps/max_channels, e status/status_updated_at (refletidos pelos eventos do FreeSWITCH, nunca escritos manualmente pela API).

Mecanismo: por que arquivo + rescan, e não XML Curl "configuration"

Ao contrário de Extensions (resolvido 100% via mod_xml_curl na hora do lookup), gateways Sofia não têm um binding XML Curl limpo e específico sem reativar a seção configuration inteira — e isso já causou problemas reais na fase XML Curl (chamadas HTTP desnecessárias no boot pra configs de outros módulos). Em vez disso, b2bcall-fs-config:

  1. Gera um arquivo <trunk_id>.xml por trunk habilitado em sip_profiles/external/ (volume Docker compartilhado com o FreeSWITCH — o profile external da config vanilla já tem <X-PRE-PROCESS cmd="include" data="external/*.xml"/>, então não precisou editar a config do profile).
  2. Roda sofia profile external rescan via ESL — relê os gateways sem derrubar chamadas em andamento (diferente de restart).

Gatilho de sincronização

apps/api não roda no Docker (ainda está no host) e fs-config não expõe porta pro host — então a notificação "algo mudou, resincroniza" vai por Redis pub/sub (b2bcall:trunks:sync), o mesmo mecanismo já usado pra eventos normalizados. apps/api publica depois de criar/apagar um trunk; fs-config também roda uma sincronização completa ao subir (cobre trunks criados enquanto ele estava fora do ar).

Achado real: a primeira sincronização no boot corria antes da conexão ESL do fs-config terminar de se estabelecer, gerando um erro cosmético ("FreeSWITCH ESL nao conectado") — a escrita dos arquivos funcionava, só o rescan falhava. Corrigido com FreeSwitchTelephonyProvider.waitUntilConnected() (timeout de 5s) antes de tentar o rescan.

Status do trunk (secao 42)

b2bcall-fs-events já escutava sofia::gateway_state desde a fase Event Socket (normalizado pra GATEWAY_UP/GATEWAY_DOWN); nesta fase, ele passou a também escrever esse estado de volta em Trunk.status. Como o nome do gateway no FreeSWITCH é o Trunk.id (UUID) e o evento não diz de qual tenant é, a busca percorre os tenants ativos (packages/database's withTenantContext) até achar o trunk dono daquele id — aceitável dado que mudança de estado de trunk é rara, não é um evento de alto volume por chamada.

Mapeamento de estados brutos do Sofia pro enum interno (apps/freeswitch-events/src/trunk-status.ts):

UP, REGED       → REGISTERED/UP
TRYING, REGISTER → TRYING
FAILED, FAIL_WAIT → FAILED
DOWN            → DOWN
NOREG, UNREGED  → UNREGISTERED
qualquer outro  → UNKNOWN

Verificado

Criei um trunk de teste apontando pra um host inexistente (sip.trunk-inexistente.invalid, nunca resolve — RFC 2606) via API:

  • fs-config sincronizou (count: 1), escreveu o arquivo .xml, rodou o rescan com sucesso.
  • sofia status gateway no FreeSWITCH mostrou o gateway real, estado FAIL_WAIT (esperado — host não existe).
  • Confirma o pipeline API → Postgres → fs-config → arquivo XML → rescan → FreeSWITCH funcionando ponta a ponta sem precisar de nenhum tronco/ credencial real.

O que falta

  • Propagação de GATEWAY_UP/DOWNTrunk.status não confirmada com evento real disparado por uma mudança de estado ao vivo nesta sessão de testes (o gateway ficou em FAIL_WAIT por falha de DNS, que pode não disparar o mesmo ciclo de eventos que uma rejeição SIP normal) — vale reverificar com um destino que responda de verdade (outro FreeSWITCH, por exemplo) antes de confiar nisso em produção.
  • Quota de troncos (max_trunks, secao 59) — depende de Plans/Entitlements.
  • GET /trunks não mostra sofia status gateway ao vivo, só o último status conhecido no banco — suficiente por enquanto, sem WebSocket ainda.