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

View File

@@ -52,9 +52,11 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
// tenant especifico depois de achar o dono do dominio.
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
if (!tenant) {
// tenant especifico depois de achar o dono do dominio. `telephonyDomain`
// e' @unique desde a PHASE 50 (achado real: antes disso todo tenant
// tinha o mesmo dominio fixo) — `findUnique` reflete essa garantia.
const tenant = await prisma.tenant.findUnique({ where: { telephonyDomain: domain } });
if (!tenant || tenant.status !== "ACTIVE") {
return NOT_FOUND_XML;
}
@@ -75,16 +77,17 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
callerIdNumber: extension.callerIdNumber ?? undefined,
tenantId: extension.tenantId,
extensionId: extension.id,
callGroup: extension.callGroup,
});
}
/**
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
* Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra
* todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o
* dialplan já tem essa variável disponível na própria chamada, então nem
* sofre da mesma ambiguidade.
* Ao contrário do directory (resolvido por domain — cada tenant tem o seu
* agora, único no banco, PHASE 50/docs/EXTENSIONS.md), o dialplan já tem
* essa variável disponível na própria chamada, então nem depende de
* domain nenhum.
*
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as