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

View File

@@ -40,13 +40,24 @@ services:
restart: unless-stopped
depends_on:
- postgres
- redis
environment:
# Hostname interno do compose (postgres), nao localhost — ver
# docs/NETWORK_ARCHITECTURE.md.
# Hostname interno do compose (postgres/redis/freeswitch), nao
# localhost — ver docs/NETWORK_ARCHITECTURE.md.
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
FS_CONFIG_USER: ${FS_CONFIG_USER}
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
ESL_HOST: freeswitch
ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD}
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
volumes:
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
# escreve os XML de gateway aqui, o profile "external" ja inclui
# sip_profiles/external/*.xml automaticamente (config vanilla).
- freeswitch_external_gateways:/gateways
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
@@ -68,6 +79,8 @@ services:
ESL_PASSWORD: ${ESL_PASSWORD}
FS_CONFIG_USER: ${FS_CONFIG_USER}
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
volumes:
- freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external
# Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais
# configurados, e o Event Socket (8021) só deve ser alcançável por outros
# containers na rede interna do compose (agente.md secao 22).
@@ -87,10 +100,12 @@ services:
depends_on:
- freeswitch
- redis
- postgres
environment:
ESL_HOST: freeswitch
ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD}
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
# Hostnames internos do compose (freeswitch/redis), diferente do
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
@@ -103,3 +118,4 @@ secrets:
volumes:
postgres_data:
redis_data:
freeswitch_external_gateways: