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
This commit is contained in:
91
docs/DIALPLAN.md
Normal file
91
docs/DIALPLAN.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user