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:
@@ -32,6 +32,9 @@ interface XmlCurlBody {
|
||||
purpose?: string;
|
||||
user?: string;
|
||||
domain?: string;
|
||||
context?: string;
|
||||
"Caller-Context"?: string;
|
||||
hostname?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -70,6 +73,38 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* linhas do editor estruturado não afeta chamadas até uma nova versão ser
|
||||
* gerada e ativada (agente.md secao 44).
|
||||
*/
|
||||
async function resolveDialplanXml(body: XmlCurlBody): Promise<string> {
|
||||
const tenantId = body["variable_b2bcall_tenant_id"] as string | undefined;
|
||||
const context = (body["Caller-Context"] ?? body.context ?? "default") as string;
|
||||
|
||||
if (!tenantId) {
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const version = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.findFirst({ where: { tenantId, context, status: "ACTIVE" } }),
|
||||
);
|
||||
if (!version) {
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
|
||||
return version.generatedXml;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expectedUser = requireEnv("FS_CONFIG_USER");
|
||||
const expectedPassword = requireEnv("FS_CONFIG_PASSWORD");
|
||||
@@ -114,8 +149,15 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// dialplan dinamico ainda nao existe (fase Dialplan) — a config
|
||||
// estatica vanilla continua respondendo por enquanto.
|
||||
if (section === "dialplan") {
|
||||
try {
|
||||
return await resolveDialplanXml(request.body ?? {});
|
||||
} catch (err) {
|
||||
logger.error("erro resolvendo dialplan", { error: String(err) });
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
}
|
||||
|
||||
return NOT_FOUND_XML;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user