feat: activate mod_xml_curl with b2bcall-fs-config (agente.md secao 26)

- apps/freeswitch-config (b2bcall-fs-config): Fastify service implementing
  the mod_xml_curl HTTP protocol (form-encoded POST -> XML response),
  containerized, no host port published
- reactivated mod_xml_curl in FreeSWITCH, binding restricted to
  directory|dialplan only (configuration was removed after testing showed
  it firing several unnecessary HTTP round-trips at boot for module
  configs we don't need dynamic — matches agente.md's own 'don't put every
  critical config through XML Curl' guidance)
- no extensions/dialplan tables exist yet (next phases), so the service
  always answers 'not found' for now — this phase only proves the wire
  protocol works without breaking the static vanilla config fallback
- verified end-to-end: user/8888 (nowhere) -> SUBSCRIBER_ABSENT via
  fs-config; user/1000 (static vanilla extension) -> USER_NOT_REGISTERED,
  proving FreeSWITCH correctly falls through to static XML when xml_curl
  says not found
- docs/XML_CURL.md, including the not-yet-authenticated endpoint note (fine
  while it only returns not-found; needs gateway-credentials before serving
  real directory/dialplan data)
This commit is contained in:
2026-08-28 07:07:05 -03:00
parent 60e9f6838e
commit d2ea83c06a
11 changed files with 240 additions and 7 deletions

16
TODO.md
View File

@@ -42,6 +42,18 @@
- [ ] Reconciliação pós-reconexão (calls/agents/queues/registrations/gateways)
— não é possível ainda, sem essas tabelas persistidas
## PHASE 07 — XML Curl (agente.md secao 26)
- [x] `apps/freeswitch-config` (b2bcall-fs-config): responde ao protocolo
XML Curl do FreeSWITCH (POST form-encoded → XML), containerizado
- [x] `mod_xml_curl` reativado, binding restrito a `directory|dialplan`
(não `configuration` — evita chamadas HTTP desnecessárias no boot)
- [x] Por enquanto sempre "not found" (sem tabela extensions/dialplan ainda);
verificado que a config estática vanilla continua funcionando como
fallback (`user/8888` → SUBSCRIBER_ABSENT via fs-config,
`user/1000` → USER_NOT_REGISTERED via config estática — achou o usuário)
- [ ] Sem autenticação HTTP ainda — ok enquanto só responde "not found";
adicionar `gateway-credentials` antes de servir directory/dialplan reais
## PHASE 02 — SaaS Core
- [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base)
- [x] Node 22 LTS + pnpm instalados no host
@@ -73,8 +85,8 @@
— testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
- [ ] Password reset por e-mail — depende de SMTP configurado
## PHASE 07+ — ver `agente.md` seções 26 em diante (XML Curl, Extensions, Trunks, Dialplan,
Call Center, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
## PHASE 08+ — ver `agente.md` seções 39 em diante (Extensions, Trunks, Dialplan, Call Center,
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
---

View File

@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1.7
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
COPY packages/types packages/types
COPY packages/shared packages/shared
COPY apps/freeswitch-config apps/freeswitch-config
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-config...
WORKDIR /repo/apps/freeswitch-config
CMD ["pnpm", "exec", "tsx", "src/main.ts"]

View File

@@ -0,0 +1,21 @@
{
"name": "@b2bcall/freeswitch-config",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@b2bcall/shared": "workspace:*",
"@fastify/formbody": "^8.0.1",
"fastify": "5.12.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.23.12",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,58 @@
import Fastify from "fastify";
import formbody from "@fastify/formbody";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-fs-config");
/**
* Resposta padrão do protocolo XML Curl pra "não achei nada aqui" — o
* FreeSWITCH cai de volta pras outras fontes XML registradas (a config
* estática vanilla continua funcionando normalmente).
*/
const NOT_FOUND_XML = `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="result">
<result status="not found"/>
</section>
</document>`;
interface XmlCurlBody {
section?: string;
tag_name?: string;
key_name?: string;
key_value?: string;
purpose?: string;
user?: string;
domain?: string;
[key: string]: unknown;
}
async function main() {
const app = Fastify({ logger: false });
await app.register(formbody);
app.post<{ Body: XmlCurlBody }>("/", async (request, reply) => {
const { section, purpose, user, domain } = request.body ?? {};
logger.info("requisicao xml_curl recebida", { section, purpose, user, domain });
// Nenhuma tabela de extensions/dialplan existe ainda (fases Extensions/
// Trunks/Dialplan, agente.md secao 232). Por enquanto respondemos
// sempre "not found" — prova o encanamento (FreeSWITCH -> mod_xml_curl
// -> este servico -> XML valido) sem afetar a config estatica vanilla,
// que continua sendo consultada como fallback.
reply.header("Content-Type", "text/xml");
return NOT_FOUND_XML;
});
app.get("/health", async () => ({ status: "ok" }));
const port = Number(process.env.PORT ?? 8080);
await app.listen({ port, host: "0.0.0.0" });
logger.info(`b2bcall-fs-config ouvindo na porta ${port}`);
}
main().catch((err) => {
logger.error("falha fatal ao iniciar b2bcall-fs-config", { error: String(err) });
process.exit(1);
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -32,6 +32,20 @@ services:
timeout: 5s
retries: 10
fs-config:
build:
context: .
dockerfile: apps/freeswitch-config/Dockerfile
container_name: b2bcall-fs-config
restart: unless-stopped
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
freeswitch:
build:
context: ./infrastructure/freeswitch
@@ -39,6 +53,8 @@ services:
- freeswitch_pat
container_name: b2bcall-freeswitch
restart: unless-stopped
depends_on:
- fs-config
environment:
ESL_PASSWORD: ${ESL_PASSWORD}
# Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais

57
docs/XML_CURL.md Normal file
View File

@@ -0,0 +1,57 @@
# XML Curl
`b2bcall-fs-config` (`apps/freeswitch-config`) responde às consultas HTTP que
`mod_xml_curl` faz ao FreeSWITCH pra Directory e Dialplan (agente.md secao 26).
## Fluxo
```
FreeSWITCH → mod_xml_curl → http://fs-config:8080/ → (nesta fase: sempre "not found")
```
O binding cobre só `directory|dialplan` (não `configuration`) — inicialmente
incluí `configuration` também, mas isso disparava várias chamadas HTTP
desnecessárias no boot do FreeSWITCH pra configs de módulo que não precisamos
controlar dinamicamente (contraria a seção 26: "Não colocar todas as
configurações críticas via XML Curl").
## Por que "not found" pra tudo, por enquanto
Não existe tabela `extensions` nem `dialplans` ainda — essas são as próximas
fases (Extensions, Trunks, Dialplan). O objetivo desta fase era só provar o
encanamento: FreeSWITCH consegue mesmo chamar nosso serviço e interpretar a
resposta corretamente, **sem quebrar a config estática vanilla que continua
valendo como fallback**.
Isso foi verificado assim:
```bash
# Usuario inexistente em qualquer lugar -> vai ate o fs-config, recebe "not
# found", FreeSWITCH reporta erro correto:
fs_cli -x "originate user/8888 &park()" # -ERR SUBSCRIBER_ABSENT
# Usuario 1000 (extensao estatica da config vanilla) -> mod_xml_curl retorna
# "not found", FreeSWITCH cai pro XML estatico e ACHA o usuario (so nao tem
# telefone registrado, que e' o esperado sem SIP real ainda):
fs_cli -x "originate user/1000 &park()" # -ERR USER_NOT_REGISTERED (nao SUBSCRIBER_ABSENT)
```
A diferença entre as duas mensagens de erro é a prova de que o fallback pra
config estática funciona corretamente.
## Segurança
Sem autenticação no endpoint HTTP por enquanto — a única proteção é isolamento
de rede (porta não publicada no host, só o FreeSWITCH na mesma rede do
Docker Compose alcança). Isso é aceitável **enquanto o serviço só responde
"not found"** (não há nada de valor pra proteger ainda). `mod_xml_curl`
suporta `gateway-credentials` (HTTP Basic) nativamente — adicionar isso
**antes** deste serviço passar a devolver directory/dialplan reais (fase
Extensions/Trunks/Dialplan), já que a partir daí ele vira fonte de verdade
para autenticação SIP.
## Próxima fase (Extensions)
Quando a tabela `extensions` existir, o handler `section === "directory"`
passa a consultar o Postgres e devolver o XML real de usuário (senha SIP,
context, etc.) em vez de "not found" fixo.

View File

@@ -45,6 +45,7 @@ RUN --mount=type=secret,id=freeswitch_pat,required=true \
COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_configs/modules.conf.xml
COPY overrides/autoload_configs/event_socket.conf.xml /etc/freeswitch/autoload_configs/event_socket.conf.xml
COPY overrides/autoload_configs/acl.conf.xml /etc/freeswitch/autoload_configs/acl.conf.xml
COPY overrides/autoload_configs/xml_curl.conf.xml /etc/freeswitch/autoload_configs/xml_curl.conf.xml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -8,11 +8,7 @@
<load module="mod_enum"/>
<!-- XML Interfaces (agente.md secao 26: mod_xml_curl para Directory/Dialplan) -->
<!-- Desativado por enquanto: mod_xml_curl recusa carregar sem pelo menos
um binding com gateway-url configurada (falha "Binding has no url!"
com o xml_curl.conf.xml default, que vem com tudo comentado). Ativar
junto com o b2bcall-fs-config na fase "XML Curl" do agente.md. -->
<!-- <load module="mod_xml_curl"/> -->
<load module="mod_xml_curl"/>
<!-- Event Handlers -->
<load module="mod_cdr_csv"/>

View File

@@ -0,0 +1,11 @@
<configuration name="xml_curl.conf" description="cURL XML Gateway">
<bindings>
<binding name="b2bcall-fs-config">
<!-- b2bcall-fs-config ainda so responde "not found" pra tudo (nao
existe extensions/dialplan persistidos ainda — fases seguintes).
A config estatica vanilla continua valendo como fallback. -->
<param name="gateway-url" value="http://fs-config:8080/" bindings="directory|dialplan"/>
<param name="timeout" value="5"/>
</binding>
</bindings>
</configuration>

35
pnpm-lock.yaml generated
View File

@@ -70,6 +70,28 @@ importers:
specifier: ^5.7.0
version: 5.9.3
apps/freeswitch-config:
dependencies:
'@b2bcall/shared':
specifier: workspace:*
version: link:../../packages/shared
'@fastify/formbody':
specifier: ^8.0.1
version: 8.0.2
fastify:
specifier: 5.12.1
version: 5.12.1
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.20.1
tsx:
specifier: ^4.23.12
version: 4.23.12
typescript:
specifier: ^5.7.0
version: 5.9.3
apps/freeswitch-events:
dependencies:
'@b2bcall/shared':
@@ -369,6 +391,9 @@ packages:
'@fastify/fast-json-stringify-compiler@5.1.0':
resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
'@fastify/formbody@8.0.2':
resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==}
'@fastify/formbody@9.0.0':
resolution: {integrity: sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==}
@@ -963,6 +988,9 @@ packages:
fast-uri@4.1.3:
resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==}
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify-plugin@6.0.0:
resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==}
@@ -1527,6 +1555,11 @@ snapshots:
dependencies:
fast-json-stringify: 7.0.1
'@fastify/formbody@8.0.2':
dependencies:
fast-querystring: 1.1.2
fastify-plugin: 5.1.0
'@fastify/formbody@9.0.0':
dependencies:
fast-querystring: 1.1.2
@@ -2164,6 +2197,8 @@ snapshots:
fast-uri@4.1.3: {}
fastify-plugin@5.1.0: {}
fastify-plugin@6.0.0: {}
fastify@5.12.1: