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
This commit is contained in:
2026-08-28 08:01:56 -03:00
parent c03c6d4eaa
commit 4c638ad496
20 changed files with 896 additions and 6 deletions

93
docs/TRUNKS.md Normal file
View File

@@ -0,0 +1,93 @@
# 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`/`DOWN` → `Trunk.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.