diff --git a/TODO.md b/TODO.md index f217e4d..737cc11 100644 --- a/TODO.md +++ b/TODO.md @@ -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) --- diff --git a/apps/freeswitch-config/Dockerfile b/apps/freeswitch-config/Dockerfile new file mode 100644 index 0000000..6b3924c --- /dev/null +++ b/apps/freeswitch-config/Dockerfile @@ -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"] diff --git a/apps/freeswitch-config/package.json b/apps/freeswitch-config/package.json new file mode 100644 index 0000000..c14e91e --- /dev/null +++ b/apps/freeswitch-config/package.json @@ -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" + } +} diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts new file mode 100644 index 0000000..56a8d2f --- /dev/null +++ b/apps/freeswitch-config/src/main.ts @@ -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 = ` + +
+ +
+
`; + +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); +}); diff --git a/apps/freeswitch-config/tsconfig.json b/apps/freeswitch-config/tsconfig.json new file mode 100644 index 0000000..3fbff51 --- /dev/null +++ b/apps/freeswitch-config/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml index aebde38..49c0ca9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/XML_CURL.md b/docs/XML_CURL.md new file mode 100644 index 0000000..e703dd1 --- /dev/null +++ b/docs/XML_CURL.md @@ -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. diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile index c798614..0246e0e 100644 --- a/infrastructure/freeswitch/Dockerfile +++ b/infrastructure/freeswitch/Dockerfile @@ -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 diff --git a/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml index 9e96e06..b8e356c 100644 --- a/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml +++ b/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml @@ -8,11 +8,7 @@ - - + diff --git a/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml new file mode 100644 index 0000000..b6ad04e --- /dev/null +++ b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd42e71..2c75620 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: