- 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
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:
- Gera um arquivo
<trunk_id>.xmlpor trunk habilitado emsip_profiles/external/(volume Docker compartilhado com o FreeSWITCH — o profileexternalda config vanilla já tem<X-PRE-PROCESS cmd="include" data="external/*.xml"/>, então não precisou editar a config do profile). - Roda
sofia profile external rescanvia ESL — relê os gateways sem derrubar chamadas em andamento (diferente derestart).
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-configsincronizou (count: 1), escreveu o arquivo.xml, rodou o rescan com sucesso.sofia status gatewayno FreeSWITCH mostrou o gateway real, estadoFAIL_WAIT(esperado — host não existe).- Confirma o pipeline
API → Postgres → fs-config → arquivo XML → rescan → FreeSWITCHfuncionando ponta a ponta sem precisar de nenhum tronco/ credencial real.
O que falta
- Propagação de
GATEWAY_UP/DOWN→Trunk.statusnão confirmada com evento real disparado por uma mudança de estado ao vivo nesta sessão de testes (o gateway ficou emFAIL_WAITpor 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 /trunksnão mostrasofia status gatewayao vivo, só o último status conhecido no banco — suficiente por enquanto, sem WebSocket ainda.