Commit Graph

3 Commits

Author SHA1 Message Date
af46739af2 fix(telefonia): pickup de grupo funcionando de verdade + RCE no dialplan + domínio real no REGISTER
Pedido explícito do usuário: "testa criando um ramal com callgroup e captura
chamada de outro ramal". O teste com softphones reais (não só leitura de
código) achou 3 problemas que a fase anterior tinha dado como resolvidos:

1. A variable de call group estava com o nome errado (`call-group`, convenção
   do Asterisk) e a suposição de que o FreeSWITCH fazia pickup automático só
   com ela era falsa. Corrigido pro nome certo (`callgroup`) e pro mecanismo
   real (fork de leg `pickup/<grupo>` no bridge + extension `*8` chamando a
   application `pickup`, lendo o grupo via `${user_data(...)}`).

2. Implementando o mecanismo acima, achada uma vulnerabilidade real de RCE: o
   allowlist de `application` no dialplan nunca bloqueava `${system(...)}`
   embutido dentro do `data` de qualquer application já permitida —
   `mod_commands` está carregado, então isso era execução de comando
   arbitrário no host do FreeSWITCH pra qualquer Tenant Admin. Corrigido com
   um segundo allowlist (`ALLOWED_INLINE_API_FUNCTIONS` +
   `IsSafeDialplanData`) que só libera funções de leitura seguras
   (`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).

3. Registrar um softphone de verdade contra o domínio do tenant (não só curl
   no mod_xml_curl) revelava 403 Forbidden: o sofia profile `internal` tinha
   `force-register-domain`/`force-subscription-domain`/
   `force-register-db-domain` fixados no domínio antigo, ignorando o domínio
   de cada tenant. Corrigido no Dockerfile do FreeSWITCH (imagem
   reconstruída, não só patch ao vivo).

Testado ponta a ponta com 3 softphones reais (linphone-cli) em containers na
mesma rede Docker: ramal do mesmo grupo captura de verdade uma ligação
tocando em outro ramal via *8 (canais confirmados bridged via `show
channels`); ramal de grupo diferente tenta e falha. RCE confirmado bloqueado
via curl (`${system(id)}` → 400) sem quebrar `${user_data(...)}` legítimo.

TODO.md (PHASE 53) e docs/EXTENSIONS.md atualizados corrigindo as afirmações
incompletas da fase anterior ("nenhuma mudança de infra necessária").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 15:24:58 -03:00
56f73bdb8f fix: domínio SIP único por tenant + grupo de captura + revelar senha do ramal
Achado real, detalhado pelo usuário testando o PABX de verdade: TenantsController
.create() gravava telephonyDomain="b2bcall.local" fixo pra TODO tenant novo —
b2bcall-fs-config decide qual tenant é dono de um REGISTER só pelo domínio
(Tenant.findFirst({telephonyDomain})), então com todo tenant no mesmo domínio o
isolamento de PABX (ramais, call groups, filas, IVR) não tinha como funcionar de
verdade. Investigado direto no container antes de mudar qualquer coisa: o sofia
profile "internal" (vanilla) já vem com <domain name="all" alias="true".../> — o
FreeSWITCH sempre aceitou domínio dinâmico por REGISTER, o bug era só a aplicação.
Nenhuma mudança de infra foi necessária.

Tenant.telephonyDomain agora é obrigatório e @unique (migration com backfill:
tenants existentes ganharam {code}.b2bcall.net, e os Extension.domain já criados
foram atualizados junto). Tela de criação de tenant sugere {code}.b2bcall.net ao
digitar o código, editável.

Extension.callGroup (novo) — ramais no mesmo grupo podem capturar a chamada um do
outro (*8), fora do grupo não. Vira a variable call-group no directory XML; a regra
de dialplan do *8 em si fica pra configurar em Telefonia > Dialplan (editor já
existe). Editável na criação e depois (PATCH /extensions/:id, novo).

POST /extensions/:id/reveal-password (novo) — achado real: "show once" puro não
funciona no dia a dia (reconfigurar um telefone/softphone precisa da senha de novo;
forçar reset toda vez derruba outro aparelho já configurado). sipPasswordEnc sempre
foi criptografia reversível, nunca hash — só não estava exposto. Auditado
(EXTENSION_PASSWORD_REVEALED) por ser sensível mesmo sem escrita.

apps/freeswitch-config reconstruído e reiniciado. Testado ponta a ponta contra o
container REAL via docker exec: senha revelada bate com a gerada na criação,
call-group aparece no XML, e o mesmo número de ramal em domínios diferentes nunca
se confunde (isolamento cross-tenant confirmado de verdade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 12:05:25 -03:00
c03c6d4eaa feat: implement Extensions with real FreeSWITCH directory integration
- 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
2026-08-28 07:39:19 -03:00