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
8.0 KiB
Extensions (Ramais)
Primeira fase que dá dado real de negócio ao FreeSWITCH via mod_xml_curl
(agente.md secao 39-40, 178).
Modelo
extensions (tenant-scoped, RLS — mesmo padrão de tenant_memberships):
number, name, domain, sip_password_enc, caller_id_name/number,
context, sofia_profile, codecs, max_registrations, enabled.
UNIQUE(tenant_id, number) — números só precisam ser únicos dentro do
tenant (secao 34).
Senha SIP (secao 178)
- Gerada com
generateStrongPassword()(24 caracteres alfanuméricos,crypto.randomBytes). - Cifrada em repouso com AES-256-GCM (
packages/shared/src/crypto.ts), chave emENCRYPTION_KEY(.env, 32 bytes hex) — nunca no PostgreSQL. - Só aparece em texto puro na resposta do
POST /extensions, uma única vez.GET/listnunca devolvemsipPasswordEncnem a senha — a funçãotoPublicExtension()faz destructuring explícito do campo (não spread) pra garantir isso é removido de fato, não só "esquecido" no tipo TypeScript.
API (apps/api/src/extensions)
POST /extensions extensions.manage cria (gera+cifra senha, devolve 1x)
GET /extensions extensions.view lista (sem senha)
GET /extensions/:id extensions.view detalhe (sem senha)
DELETE /extensions/:id extensions.manage soft delete (deletedAt + enabled=false)
Novo PermissionGuard genérico (@RequirePermission('extensions.manage'))
— roda depois do JwtAuthGuard, exige tenant selecionado no JWT (nunca
aceita tenant_id do client) e chama userHasPermission() de packages/auth.
Achado real durante os testes: o PermissionGuard injeta Reflector via
construtor — padrão documentado do NestJS. Rodando via tsx (esbuild), o
reflector chegava undefined em runtime (TypeError: Cannot read properties of undefined), porque esbuild não faz emissão de
design:paramtypes com checagem de tipos completa entre arquivos (limitação
conhecida do esbuild, diferente do tsc). Resolvido com @Inject(Reflector)
explícito no construtor. Isso é um risco real pra qualquer guard/serviço
futuro que dependa de injeção implícita de tipo — usar @Inject()
explícito sempre que o dev/runtime for via tsx, ou considerar migrar
apps/api pra build real (tsc) mais adiante.
Atualização (fase Dialplan): esse "considerar migrar" virou obrigatório
— o mesmo problema de metadata do esbuild também desativava silenciosamente
o ValidationPipe inteiro (sem crash, sem log, só aceitando qualquer
entrada). apps/api agora sempre builda com tsc antes de rodar. Ver
docs/VALIDATION_PIPE_BUG.md.
b2bcall-fs-config agora responde directory de verdade
Fluxo section === "directory":
Tenant.findFirst({ telephonyDomain: domain, status: "ACTIVE" })— tenants não são RLS-protected (é o registro da plataforma).withTenantContext(tenant.id) → Extension.findFirst({ number: user, enabled: true }).buildDirectoryUserXml()(packages/telephony) monta o XML, incluindob2bcall_tenant_id/b2bcall_extension_idcomo channel variables (secao 81 — assim qualquer chamada desse ramal já carrega a origem).
Achado real: a primeira versão do XML não incluía o bloco
<domain><params><param name="dial-string".../></params></domain> que a
config vanilla tem em directory/default.xml. Sem isso, originate user/1500 &park() falhava com MANDATORY_IE_MISSING em vez do
USER_NOT_REGISTERED esperado — o FreeSWITCH não sabia montar o dialstring
pro endpoint user/. Corrigido copiando o mesmo template de dial-string da
config vanilla.
$${domain} fixo (só o valor default do container, não uma restrição)
Tenant.telephonyDomain precisa bater com o que o FreeSWITCH manda como
domain no POST do xml_curl. Por padrão, a config vanilla usa
domain=$${local_ip_v4} — o IP do container, que muda a cada restart e
nunca seria estável o suficiente pra configurar em um tenant. Corrigido no
Dockerfile do FreeSWITCH com um sed fixando $${domain} pra
b2bcall.local (configurável via ARG DEFAULT_SIP_DOMAIN) — isso é só o
valor usado como REFERÊNCIA/fallback em vars.xml, não uma restrição de
quais domínios o profile aceita.
Multi-domínio real por tenant — resolvido na PHASE 50 (achado real
reportado pelo usuário testando): o profile internal (vanilla, não
sobrescrito) já vem com <domain name="all" alias="true" parse="false"/>
— aceita REGISTER de qualquer domínio dinamicamente, sempre dependeu só do
section=directory do mod_xml_curl resolver o domínio certo (que já
funciona por request, olhando o domain que o UA manda). O bug de verdade
era só a camada de aplicação: TenantsController.create() gravava o mesmo
telephonyDomain fixo ("b2bcall.local") pra todo tenant novo. Corrigido:
telephonyDomain agora é obrigatório e único na criação do tenant
(constraint no banco), sugerido como {code}.b2bcall.net na tela mas
editável. Nenhuma mudança de infra (Dockerfile/sofia profile) foi
necessária — só era preciso um domínio de verdade por tenant.
Verificado ponta a ponta
POST /extensions {"number":"1500","name":"Ramal de Teste"} # 201, senha aparece 1x
GET /extensions/:id # confirma senha nunca reaparece
# fs-config resolve com a senha certa (decifrada corretamente):
curl -d "section=directory&user=1500&domain=b2bcall.local" http://fs-config:8080/
# FreeSWITCH:
originate user/1500 &park() # USER_NOT_REGISTERED (achou o ramal, sem telefone registrado)
originate user/1501 &park() # SUBSCRIBER_ABSENT (nao existe)
DELETE /extensions/:id
originate user/1500 &park() # volta a SUBSCRIBER_ABSENT
Autenticação HTTP fs-config ↔ FreeSWITCH
Adicionada nesta mesma fase (assim que o serviço passou a devolver dados
reais, deixou de ser opcional): HTTP Basic, credenciais em FS_CONFIG_USER/
FS_CONFIG_PASSWORD (.env, geradas com openssl rand). FreeSWITCH manda via
gateway-credentials em xml_curl.conf.xml (substituído em runtime pelo
entrypoint.sh, mesmo padrão do ESL_PASSWORD — nunca fica na imagem).
fs-config compara com timingSafeEqual (evita timing attack), libera só
/health sem auth (usado pelo healthcheck do Docker). Verificado: requisição
sem credenciais recebe 401; o FreeSWITCH (com gateway-credentials
configurado) continua funcionando normalmente.
Senha SIP: "ver de novo" além do "show once" (PHASE 51)
Achado real reportado pelo usuário: "show once" puro não funciona no dia a
dia — reconfigurar um telefone físico ou softphone precisa da senha de
novo, e forçar reset toda vez (POST /:id/reset-password) 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. POST /extensions/:id/reveal-password (novo)
decifra e devolve a senha ATUAL sem trocar nada, auditado
(EXTENSION_PASSWORD_REVEALED) por ser uma ação sensível mesmo sem
escrita nenhuma.
Grupo de captura (call group, PHASE 51)
Achado real: sem isso, qualquer ramal conseguia capturar a chamada de
qualquer outro (o PBX não tinha noção de "grupo"). Extension.callGroup
(nullable) vira a variable call-group no directory XML — o FreeSWITCH já
resolve *8 (group pickup) comparando essa variable entre canais. A
extensão de dialplan pro *8 em si fica pra configurar em Telefonia >
Dialplan (já é um editor de regras por tenant), não precisa de código
novo — só precisava existir o dado.
O que falta
Quota de ramais— implementada na fase Plans/Entitlements (ver docs/ENTITLEMENTS.md),assertQuotachamado antes de criar.- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
Multi-domínio real por tenant— resolvido na PHASE 50 (ver acima).- Extensão de dialplan padrão pro
*8de group pickup — o dado (callGroup) já existe, falta só alguém configurar a regra em Telefonia > Dialplan (ou decidir seedar uma default por tenant).