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
This commit is contained in:
2026-08-30 12:05:25 -03:00
parent 7bc4b2e819
commit 56f73bdb8f
24 changed files with 498 additions and 30 deletions

49
TODO.md
View File

@@ -1828,6 +1828,55 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
mas o error boundary do client detecta, desloga e redireciona pro
login sozinho — sem crash na tela pro usuário
## PHASE 52 — Domínio SIP por tenant + grupo de captura + revelar senha
(achado real detalhado pelo usuário, testando o PABX de verdade)
- [x] achado real (o mais sério dos três): `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: domain })`),
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: o sofia profile `internal`
(vanilla, nunca sobrescrito) já vem com
`<domain name="all" alias="true" parse="false"/>` — o FreeSWITCH
SEMPRE aceitou domínio dinâmico por REGISTER, o bug era só a
aplicação gravando o mesmo valor pra todo mundo. Nenhuma mudança de
infra (Dockerfile/sofia profile) foi necessária
- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration
`20260830140000_tenant_domain_and_call_group`, com backfill:
tenants existentes ganharam `{code}.b2bcall.net` automaticamente,
e os `Extension.domain` já criados foram atualizados junto — sem
isso ficariam com um `<domain>` desatualizado no XML). Tela de
criação de tenant sugere `{code}.b2bcall.net` ao digitar o código,
editável antes de criar. Duplicado vira 403 claro
- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao
178): ramais no mesmo grupo podem atender a chamada um do outro
(`*8`/group pickup), ramais fora do grupo não. Vira a variable
`call-group` no directory XML (`packages/telephony`); a extensão de
dialplan do `*8` em si é uma regra a configurar em Telefonia >
Dialplan (já existe o editor), não precisava de código novo — só do
dado. Editável na criação e depois (`PATCH /extensions/:id`, novo)
- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show
once" puro não funciona no dia a dia de um PABX (reconfigurar um
telefone físico ou softphone precisa da senha de novo; forçar reset
toda vez derruba o registro de qualquer aparelho já configurado
com a senha antiga). `sipPasswordEnc` sempre foi criptografia
reversível (AES-256-GCM), nunca hash — só não estava exposto.
Diferente de `reset-password`: nunca troca nada, só decifra a
atual. Auditado (`EXTENSION_PASSWORD_REVEALED`) por ser sensível
mesmo sem escrita
- [x] Tela de criação de ramal ganhou um bloco "Configuração do telefone/
softphone" (domínio, ramal, proxy=servidor, senha) — os 4 campos
exatos que o usuário descreveu precisar pra configurar um aparelho
- [x] `apps/freeswitch-config` reconstruído e reiniciado (mudança de
schema + lógica) — testado ponta a ponta contra o container REAL:
criado tenant com domínio próprio + ramal com callGroup, `docker
exec` no FreeSWITCH chamando `fs-config` direto (mesma rede
Docker) confirmou (1) senha revelada bate exatamente com a gerada
na criação, (2) `call-group` aparece no XML, (3) o mesmo número de
ramal em domínios diferentes NUNCA se confunde (isolamento
cross-tenant confirmado de verdade, não só por leitura de código)
---
## Riscos conhecidos