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:
101
packages/telephony/src/dialplan-xml.ts
Normal file
101
packages/telephony/src/dialplan-xml.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Applications de dialplan permitidas (agente.md secao 180: nunca deixar
|
||||
* input de tenant virar comando arbitrário no FreeSWITCH — "system",
|
||||
* "exec", "socket" etc. ficam de fora de propósito, mesmo que existam
|
||||
* módulos capazes de rodá-las).
|
||||
*/
|
||||
export const ALLOWED_DIALPLAN_APPLICATIONS = [
|
||||
"answer",
|
||||
"pre_answer",
|
||||
"bridge",
|
||||
"hangup",
|
||||
"park",
|
||||
"playback",
|
||||
"ring_ready",
|
||||
"respond",
|
||||
"set",
|
||||
"export",
|
||||
"transfer",
|
||||
"sleep",
|
||||
"record_session",
|
||||
] as const;
|
||||
|
||||
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number];
|
||||
|
||||
export const ALLOWED_CONDITION_FIELDS = [
|
||||
"destination_number",
|
||||
"caller_id_number",
|
||||
"caller_id_name",
|
||||
"context",
|
||||
"network_addr",
|
||||
"source",
|
||||
] as const;
|
||||
|
||||
export type AllowedConditionField = (typeof ALLOWED_CONDITION_FIELDS)[number];
|
||||
|
||||
export interface DialplanAction {
|
||||
application: AllowedDialplanApplication;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface DialplanExtensionInput {
|
||||
name: string;
|
||||
conditionField: AllowedConditionField;
|
||||
conditionExpr: string;
|
||||
actions: DialplanAction[];
|
||||
antiActions?: DialplanAction[];
|
||||
continueOnFalse: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | undefined): string {
|
||||
if (!actions || actions.length === 0) return "";
|
||||
return actions
|
||||
.map(
|
||||
(a) =>
|
||||
` <${tag} application="${xmlEscape(a.application)}"${
|
||||
a.data !== undefined ? ` data="${xmlEscape(a.data)}"` : ""
|
||||
}/>`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* XML de dialplan (agente.md secao 43-44). Uma condição por extension —
|
||||
* simplificação deliberada em relação ao FreeSWITCH puro (que permite
|
||||
* múltiplas <condition> por extension); cobre o editor estruturado descrito
|
||||
* na especificação sem a complexidade de encadeamento arbitrário.
|
||||
*/
|
||||
export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string {
|
||||
const sorted = [...extensions].sort((a, b) => a.order - b.order);
|
||||
|
||||
const extensionsXml = sorted
|
||||
.map((ext) => {
|
||||
const actionsBlock = actionsXml("action", ext.actions);
|
||||
const antiActionsBlock = actionsXml("anti-action", ext.antiActions);
|
||||
return ` <extension name="${xmlEscape(ext.name)}" continue="${ext.continueOnFalse ? "true" : "false"}">
|
||||
<condition field="${xmlEscape(ext.conditionField)}" expression="${xmlEscape(ext.conditionExpr)}">
|
||||
${actionsBlock}${antiActionsBlock ? `\n${antiActionsBlock}` : ""}
|
||||
</condition>
|
||||
</extension>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="freeswitch/xml">
|
||||
<section name="dialplan">
|
||||
<context name="${xmlEscape(context)}">
|
||||
${extensionsXml}
|
||||
</context>
|
||||
</section>
|
||||
</document>`;
|
||||
}
|
||||
Reference in New Issue
Block a user