Files
B2BCall-dialer/docs/DIALPLAN.md
Matheus 0720a0efe3 feat: implement Dialplan with structured editor and versioning
- dialplan_extensions table (tenant-scoped, RLS): structured editor per
  agente.md secao 43 -- context, condition field/expr, actions/anti-actions
  (JSON), continue, order, enabled. One condition per extension (deliberate
  simplification vs raw FreeSWITCH's multi-condition extensions).
- dialplan_versions table (tenant-scoped, RLS): generate/validate/version/
  activate flow (secao 44). Reactivating an older version IS the rollback
  mechanism -- no separate endpoint needed.
- apps/api/src/dialplan: extensions CRUD + versions/generate (builds XML,
  validates well-formedness with fast-xml-parser, saves as DRAFT) +
  versions/:id/activate (atomically flips ACTIVE, supersedes the previous
  one). Reused freeswitch.view/.configure permissions rather than inventing
  new ones not in the agente.md permission list.
- packages/telephony: buildDialplanXml() plus ALLOWED_DIALPLAN_APPLICATIONS,
  an explicit allowlist (answer/bridge/playback/hangup/set/export/... --
  deliberately no system/exec/socket) guarding against a tenant configuring
  a dialplan action that runs arbitrary commands on the FreeSWITCH host
  (agente.md secao 180)
- b2bcall-fs-config resolves dialplan dynamically per call (unlike Trunks'
  file+rescan approach -- dialplan is fetched fresh via mod_xml_curl on
  every call anyway) by tenant id from the variable_b2bcall_tenant_id
  channel variable already injected at directory resolution, then serving
  whichever DialplanVersion is ACTIVE for that context
- verified end-to-end: created a rule for destination_number 7000, generated
  and activated v1, originated a call that actually routed through the
  dialplan (not bypassing it via &app()) -- CALL_CREATED -> CALL_ANSWERED ->
  CALL_ENDED with the correct tenantId throughout. Created and activated a
  v2, then rolled back to v1 by reactivating it; status transitions
  (ACTIVE/SUPERSEDED) all confirmed via the API.

CRITICAL FINDING, fixed in this same phase: deliberately testing that the
application allowlist rejects 'system' got back 201 instead of 400 --
NestJS's ValidationPipe had been silently inert across all of apps/api's
@Body() DTOs since the API was first created. Root cause: running via
 (esbuild) instead of a real  build -- esbuild doesn't always
resolve cross-file parameter types for design:paramtypes metadata, and Nest
skips validation without any error when it can't determine the DTO class.
Fixed by always building with tsc before running (tsc && tsx dist/main.js
-- still via tsx because internal workspace packages aren't built to JS
yet). Re-verified with two deliberate bad-input tests post-fix, both
correctly rejected with 400. A stray malicious test row (dialplan action
'system') created while the bug was live was deleted; it was never baked
into an activated version, so nothing could have executed it.
See docs/VALIDATION_PIPE_BUG.md for the full writeup.

docs/DIALPLAN.md, docs/VALIDATION_PIPE_BUG.md, docs/EXTENSIONS.md updated
2026-08-28 08:41:45 -03:00

4.4 KiB

Dialplan

Agente.md secao 43-44. Editor estruturado (não textarea) + versionamento com ativação/rollback explícitos.

Modelo

  • dialplan_extensions (tenant-scoped, RLS): fonte editável — context, name, conditionField/conditionExpr (uma condição por extension, simplificação deliberada em relação ao FreeSWITCH puro, que permite várias <condition> por extension), actions/antiActions (JSON, array de {application, data}), continueOnFalse, order, enabled.
  • dialplan_versions (tenant-scoped, RLS): snapshot gerado — context, version (incremental por tenant+context), generatedXml, status (DRAFT/ACTIVE/SUPERSEDED), createdByUserId, activatedAt.

Fluxo (agente.md secao 44)

POST /dialplan/extensions          -- edita as linhas (não afeta chamadas)
POST /dialplan/versions/generate   -- gera XML a partir das linhas atuais,
                                       valida bem-formação (fast-xml-parser),
                                       salva como nova versão DRAFT
POST /dialplan/versions/:id/activate -- marca ACTIVE, a anterior vira
                                       SUPERSEDED (transação atômica)

Reativar uma versão antiga é o próprio rollback — não existe endpoint separado. GET /dialplan/versions mostra o histórico completo.

Mecanismo: dinâmico por chamada, não arquivo+rescan

Ao contrário de Trunks (gateways carregados uma vez no boot/rescan), dialplan é resolvido pelo FreeSWITCH a cada chamada via mod_xml_curl — então não precisa de sincronização por arquivo nem de avisar o FreeSWITCH quando uma versão é ativada. b2bcall-fs-config serve generatedXml da versão ACTIVE diretamente na resposta HTTP; "ativar" uma versão tem efeito imediato na próxima chamada.

Resolução de tenant é feita pelo channel variable variable_b2bcall_tenant_id — que já vem setado desde o registro/ autenticação do ramal (buildDirectoryUserXml, fase Extensions). Diferente do directory (resolvido por domain, hoje um valor fixo compartilhado por todos os tenants — limitação conhecida), o dialplan não sofre dessa ambiguidade porque a variável já está no contexto da própria chamada.

Segurança: allowlist de applications (agente.md secao 180)

ALLOWED_DIALPLAN_APPLICATIONS (packages/telephony/src/dialplan-xml.ts) restringe action.application a um conjunto seguro (answer, bridge, playback, hangup, set, export, ...) — de propósito sem system, exec, socket/Lua-eval ou qualquer application capaz de rodar comandos no host. Sem isso, um tenant_admin malicioso ou comprometido poderia configurar uma "rota de dialplan" que executa comandos arbitrários no servidor FreeSWITCH.

Achado crítico durante os testes desta fase

Ao testar deliberadamente que a allowlist rejeitava application: "system", a API aceitou com 201 — a validação inteira de apps/api (todo @Body(), em todos os controllers, desde que a API foi criada) estava silenciosamente inoperante rodando via tsx. Causa raiz, correção e o que foi limpo: ver docs/VALIDATION_PIPE_BUG.md (achado grande o suficiente pra merecer documento próprio). Resumo da correção: apps/api agora builda com tsc de verdade antes de rodar — nunca mais tsx src/main.ts direto.

Verificado ponta a ponta (já com a validação corrigida)

POST /dialplan/extensions {"conditionExpr":"^7000$","actions":[answer,playback,hangup]}
POST /dialplan/versions/generate?context=default   → v1 DRAFT
POST /dialplan/versions/:id/activate               → v1 ACTIVE

originate {b2bcall_tenant_id=<tenant>}null/_test_ 7000 XML default
→ fs-events: CALL_CREATED → CALL_ANSWERED → CALL_ENDED (tenantId correto)

Criei uma segunda versão (v2, rota diferente), ativei — v1 virou SUPERSEDED. Reativei v1 (rollback) — v2 virou SUPERSEDED, v1 voltou pra ACTIVE. Confirmado com GET /dialplan/versions.

O que falta

  • Só uma <condition> por extension (simplificação) — FreeSWITCH suporta múltiplas em sequência; revisitar se algum caso de uso real precisar.
  • dialplan.view/dialplan.manage não existem como permissions próprias — reusei freeswitch.view/freeswitch.configure (já existentes na seção 145 do agente.md), que cobrem semanticamente "configuração do FreeSWITCH". Se precisar de granularidade maior no futuro, criar permissions dedicadas.
  • Sem UI ainda (fase Frontend).