# 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 em `ENCRYPTION_KEY` (.env, 32 bytes hex) — nunca no PostgreSQL. - **Só aparece em texto puro na resposta do `POST /extensions`, uma única vez.** `GET`/`list` nunca devolvem `sipPasswordEnc` nem a senha — a função `toPublicExtension()` 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"`: 1. `Tenant.findFirst({ telephonyDomain: domain, status: "ACTIVE" })` — tenants não são RLS-protected (é o registro da plataforma). 2. `withTenantContext(tenant.id) → Extension.findFirst({ number: user, enabled: true })`. 3. `buildDirectoryUserXml()` (`packages/telephony`) monta o XML, incluindo `b2bcall_tenant_id`/`b2bcall_extension_id` como 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 `` 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 `` — 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. **Correção (PHASE 53)**: o parágrafo acima concluía "nenhuma mudança de infra foi necessária", baseado só em testar o `mod_xml_curl` via curl direto (que de fato já funcionava por domínio). Isso era **incompleto** — só um teste de REGISTER de verdade, com um softphone real, achou que o profile `internal` TAMBÉM tinha `force-register-domain`/ `force-subscription-domain`/`force-register-db-domain` fixados em `$${domain}`, ignorando completamente o domínio do REGISTER e sempre resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer domínio real de tenant). O `` citado acima só afeta resolução de contexto de dialplan — não essa checagem de REGISTER, que é um código completamente separado dentro do sofia profile. Corrigido no `Dockerfile` removendo os 3 params (mesmo `sed` do `$${domain}` acima) — procedimento padrão documentado do próprio FreeSWITCH pra multi-domínio. Ver PHASE 53 no `TODO.md` pro teste completo. ## Verificado ponta a ponta ```bash 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/53) 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 `callgroup` no directory XML. **Correção (PHASE 53)**: o parágrafo original dizia que a variable era `call-group` (com hífen, convenção do Asterisk) e que o FreeSWITCH "já resolve `*8` sozinho comparando essa variable entre canais" — ambas as afirmações estavam erradas, e nunca tinham sido testadas com uma chamada de verdade. O mecanismo real, confirmado contra a documentação oficial do FreeSWITCH e testado ponta a ponta com softphones reais (PHASE 53): 1. A variable correta é `callgroup`, sem hífen — não tem nenhum efeito automático sozinha. É lida no dialplan via `${user_data(@ var callgroup)}` (API `mod_commands`, já carregada). 2. O `bridge` que atende a ligação pro ramal chamado precisa forkar um leg extra `pickup/` junto do `user/...` normal — isso é o que registra a ligação tocando num hash em memória sob a chave do grupo: `bridge data="user/${destination_number}@${domain_name},pickup/${called_party_callgroup}"`. 3. Uma extension de feature code separada (`*8`) precisa chamar a application `pickup` (adicionada ao `ALLOWED_DIALPLAN_APPLICATIONS`) com o grupo do PRÓPRIO CALLADOR como `data`. Essas duas regras de dialplan (bridge com o pickup fork + `*8`) foram configuradas de verdade no tenant Acme via Telefonia > Dialplan — não é mais só "o dado existe, falta configurar a regra". Testado com 3 softphones reais: ramal do mesmo grupo captura a ligação tocando (`*8` funciona e o canal migra de verdade); ramal de outro grupo tenta `*8` na mesma ligação e falha (nenhuma captura). Ver PHASE 53 no `TODO.md`. ### Achado de segurança relacionado: RCE via função inline no dialplan (PHASE 53) Implementando o mecanismo acima, foi descoberto que o allowlist de `application` (`set`/`export`/`playback`/...) nunca bloqueava `${nome(args)}` — uma chamada de API do FreeSWITCH — embutida dentro do `data` de uma application já permitida. Como `mod_commands` está carregado, isso permitia `${system(...)}`/`${bg_system(...)}` e RCE completo no host do FreeSWITCH pra qualquer Tenant Admin com permissão `freeswitch.configure`. Corrigido com um segundo allowlist, `ALLOWED_INLINE_API_FUNCTIONS` (`packages/telephony`), validado no DTO via `IsSafeDialplanData` (`apps/api/src/dialplan/dto/`) — bloqueia qualquer `${nome(...)}` fora de um punhado de funções de leitura (`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`). `${variavel}` sem parênteses nunca é afetado. ## O que falta - ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar. - Tela "Telefonia → Ramais" (frontend) — já existe (ver PHASE 30). - ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50/53 (ver acima). - ~~Extensão de dialplan padrão pro `*8` de group pickup~~ — configurada de verdade no tenant Acme na PHASE 53 (ver acima); ainda não é seedada automaticamente pra tenant novo, decisão de produto em aberto.