From b3b0aaacb3a9c911329d0168c95a4c3191d45f59 Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 06:30:25 -0300 Subject: [PATCH] feat: add FreeSWITCH service (SignalWire packages, not compiled from source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infrastructure/freeswitch/Dockerfile: debian:trixie-slim + SignalWire packaged freeswitch-meta-vanilla, avoiding a C/C++ build on a 1.9GB RAM VM - FREESWITCH_PAT used only via Docker BuildKit secret, apt credentials file created and deleted within the same RUN — verified absent from the final image with docker history - minimal module set (agente.md secao 15): sofia, event_socket, commands, dptools, callcenter, avmd, curl, local_stream, etc. mod_xml_curl installed but disabled — it refuses to load without a configured gateway-url, which will exist once b2bcall-fs-config is built - entrypoint.sh rotates the Event Socket password away from the 'ClueCon' default at container runtime (never baked into the image); fails loudly if ESL_PASSWORD is unset - port 8021 not published to the host; only reachable from other containers on the compose network - found and fixed: freeswitch-conf-vanilla is a Recommends (not a Depends) of freeswitch-meta-vanilla, so --no-install-recommends silently produced an empty /etc/freeswitch and a crash loop - verified end-to-end: fs_cli status via ESL with the custom password, default password rejected, expected modules loaded, healthcheck green, ~44MB RAM usage - docs/FREESWITCH.md, docs/NETWORK_ARCHITECTURE.md (network_mode decision deferred until a real SIP trunk exists) --- TODO.md | 29 ++++-- docker-compose.yml | 23 +++++ docs/FREESWITCH.md | 95 +++++++++++++++++++ docs/NETWORK_ARCHITECTURE.md | 36 +++++++ infrastructure/freeswitch/Dockerfile | 52 ++++++++++ infrastructure/freeswitch/entrypoint.sh | 13 +++ .../autoload_configs/modules.conf.xml | 75 +++++++++++++++ 7 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 docs/FREESWITCH.md create mode 100644 docs/NETWORK_ARCHITECTURE.md create mode 100644 infrastructure/freeswitch/Dockerfile create mode 100644 infrastructure/freeswitch/entrypoint.sh create mode 100644 infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml diff --git a/TODO.md b/TODO.md index 0a23384..af8e4b4 100644 --- a/TODO.md +++ b/TODO.md @@ -8,9 +8,25 @@ - [x] Redis 7 (docker-compose, porta 127.0.0.1:6379) - [x] Secrets gerados em `.env` (POSTGRES_PASSWORD, REDIS_PASSWORD, JWT_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY, ESL_PASSWORD) - [x] `FREESWITCH_PAT` configurado em `.env` (não commitado) -- [ ] FreeSWITCH (build/imagem própria, ver risco de RAM abaixo) +- [x] FreeSWITCH (imagem própria via pacotes SignalWire, não compilada da fonte — + ver docs/FREESWITCH.md; rodando, saudável, ~44MB RAM, senha ESL customizada, + nenhuma porta exposta ao host) - [ ] nginx (reverse proxy) +## PHASE 05 — FreeSWITCH (agente.md secao 232) +- [x] Imagem própria (`infrastructure/freeswitch/`), pacotes SignalWire (PAT via + BuildKit secret, nunca na imagem final — verificado com `docker history`) +- [x] Módulos mínimos carregados: sofia, event_socket, commands, dptools, + callcenter, avmd, curl, local_stream, etc. (mod_xml_curl instalado mas + desativado até existir b2bcall-fs-config) +- [x] Senha do Event Socket trocada da padrão via entrypoint runtime (nunca + fica na imagem); porta 8021 não publicada no host +- [x] docs/FREESWITCH.md, docs/NETWORK_ARCHITECTURE.md (decisão de + network_mode adiada pra quando existir tronco SIP real) +- [ ] Diretório/dialplan ainda são os estáticos da config vanilla (ramais de + teste 1000-1019, senhas fracas) — substituir por mod_xml_curl na fase + Extensions/Trunks/Dialplan + ## PHASE 02 — SaaS Core - [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base) - [x] Node 22 LTS + pnpm instalados no host @@ -42,14 +58,15 @@ — testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429) - [ ] Password reset por e-mail — depende de SMTP configurado -## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center, -Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) +## PHASE 06+ — ver `agente.md` seções 21 em diante (Event Socket, XML Curl, Telefonia, +Call Center, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- ## Riscos conhecidos -- **RAM da VM (1.9GB total)**: insuficiente para rodar toda a stack (Postgres + Redis + - FreeSWITCH + múltiplos workers Node + Next.js) simultaneamente sem swap/OOM. Avaliar - upgrade de RAM antes de subir FreeSWITCH + frontend + workers juntos. +- **RAM da VM (1.9GB total)**: medido com Postgres+Redis+FreeSWITCH rodando juntos — + ~91MB no total (Postgres 37MB, Redis 10MB, FreeSWITCH 44MB), bem tranquilo. O risco + real ainda não testado é o build/runtime do Next.js (frontend) e vários workers Node + simultâneos — reavaliar quando chegarmos lá. - **Disco (26GB livre)**: build do FreeSWITCH + imagens Docker + gravações vão consumir espaço rápido. Monitorar com `df -h`. diff --git a/docker-compose.yml b/docker-compose.yml index cca9712..ec9636c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,29 @@ services: timeout: 5s retries: 10 + freeswitch: + build: + context: ./infrastructure/freeswitch + secrets: + - freeswitch_pat + container_name: b2bcall-freeswitch + restart: unless-stopped + environment: + ESL_PASSWORD: ${ESL_PASSWORD} + # Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais + # configurados, e o Event Socket (8021) só deve ser alcançável por outros + # containers na rede interna do compose (agente.md secao 22). + healthcheck: + test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + +secrets: + freeswitch_pat: + environment: FREESWITCH_PAT + volumes: postgres_data: redis_data: diff --git a/docs/FREESWITCH.md b/docs/FREESWITCH.md new file mode 100644 index 0000000..e273313 --- /dev/null +++ b/docs/FREESWITCH.md @@ -0,0 +1,95 @@ +# FreeSWITCH + +Imagem própria em `infrastructure/freeswitch/` (agente.md secao 19), rodando como +serviço Docker `freeswitch` (container `b2bcall-freeswitch`). + +## Por que pacotes prontos, e não compilar da fonte + +O servidor de desenvolvimento tem só ~1.9GB de RAM. Compilar FreeSWITCH (C/C++, +dezenas de módulos) arriscaria OOM e levaria muito tempo. Em vez disso, a imagem +usa os pacotes `.deb` pré-compilados do repositório oficial do SignalWire +(`freeswitch.signalwire.com`), autenticado com `FREESWITCH_PAT` — exatamente o +uso previsto para essa credencial (agente.md secao 11). O repositório do +SignalWire tem pacotes para o codename `trixie`, então a imagem usa +`debian:trixie-slim` como base (mesma versão do host, mas isso é coincidência — +o container é isolado e poderia usar qualquer Debian suportado pelo repo). + +## Manuseio do PAT (agente.md secao 11) + +`FREESWITCH_PAT` entra no build via **Docker BuildKit secret** +(`--mount=type=secret,id=freeswitch_pat`), nunca como `ARG`/`ENV`. O arquivo de +credenciais do apt (`/etc/apt/auth.conf.d/freeswitch.conf`, que contém o PAT em +texto puro) é criado e apagado dentro do **mesmo** `RUN`, então nunca aparece em +nenhuma camada da imagem final — verificado com `docker history --no-trunc`. + +`docker-compose.yml` declara o secret assim: + +```yaml +secrets: + freeswitch_pat: + environment: FREESWITCH_PAT +``` + +e o serviço `freeswitch` referencia `secrets: [freeswitch_pat]` em `build:`. + +## Pacotes instalados + +``` +freeswitch-meta-vanilla # core + config de referência (a mesma usada em + # praticamente todo tutorial/livro de FreeSWITCH) +freeswitch-conf-vanilla # ⚠ Recommends de meta-vanilla, não Depends — + # precisa ser listado explicitamente com + # --no-install-recommends (foi um bug real + # durante o setup: sem isso /etc/freeswitch + # fica vazio e o container entra em crash loop) +freeswitch-mod-callcenter # ACD (agente.md secao 37) +freeswitch-mod-avmd # detecção de caixa postal/beep (secao 87) +freeswitch-mod-curl # chamadas HTTP a partir do dialplan +``` + +`mod_xml_curl` está **instalado mas desativado** em +`overrides/autoload_configs/modules.conf.xml` — o módulo se recusa a carregar +sem pelo menos um binding com `gateway-url` configurada ("Binding has no +url!"), e essa URL só existirá quando o `b2bcall-fs-config` for criado (fase +"XML Curl", logo em seguida). Reativar lá. + +`mod_signalwire` foi removido da lista de módulos: é específico da nuvem do +SignalWire, que não usamos (só o repositório de pacotes). + +## Event Socket (agente.md secao 22) + +Senha alterada da padrão (`ClueCon`) para `${ESL_PASSWORD}` (gerado com +`openssl rand`, vive só em `.env`) via `entrypoint.sh`, que faz um `sed` no +`event_socket.conf.xml` **em runtime** — a senha real nunca é copiada para a +imagem, só injetada via variável de ambiente do container. O entrypoint falha +alto (`set -eu` + `${ESL_PASSWORD:?...}`) se a variável não estiver definida — +nunca sobe com a senha padrão por engano. + +Porta 8021 **não é publicada no host** (`ports:` ausente no compose) — só +alcançável por outros containers na rede interna do Docker Compose +(`b2bcall_default`), pelo nome de serviço `freeswitch`. Isso satisfaz +"NÃO pública... acesso somente pela aplicação autorizada" sem precisar de ACL +adicional por enquanto (ACL fica como hardening futuro, ver TODO). + +## O que NÃO foi feito nesta fase (fica para as próximas, por design) + +Seguindo a ordem do próprio `agente.md` (secao 232): esta fase só cobre "ter o +FreeSWITCH rodando e alcançável". As próximas fases constroem em cima: + +- **Event Socket**: `b2bcall-fs-events`, conexão ESL permanente (secao 21). +- **XML Curl**: `b2bcall-fs-config`, reativa `mod_xml_curl` apontando pra esse + serviço (secao 26). +- **Extensions/Trunks/Dialplan**: hoje o directory/dialplan estático da config + vanilla continua com os 20 ramais de teste (1000-1019, senhas fracas — README + do próprio pacote avisa isso). Como as portas SIP não estão publicadas no + host, isso fica contido, mas precisa ser substituído por `mod_xml_curl` + dinâmico antes de qualquer tronco/ramal real existir. +- **mod_odbc_cdr**: não instalado ainda — só faz sentido junto da fase de CDR. + +## Verificação manual + +```bash +source <(grep '^ESL_PASSWORD=' .env) +docker exec b2bcall-freeswitch fs_cli -p "$ESL_PASSWORD" -x "status" +docker exec b2bcall-freeswitch fs_cli -p "$ESL_PASSWORD" -x "module_exists mod_sofia" +``` diff --git a/docs/NETWORK_ARCHITECTURE.md b/docs/NETWORK_ARCHITECTURE.md new file mode 100644 index 0000000..01ab79a --- /dev/null +++ b/docs/NETWORK_ARCHITECTURE.md @@ -0,0 +1,36 @@ +# Arquitetura de Rede + +## Estado atual (fase FreeSWITCH inicial) + +`freeswitch` roda na rede padrão do Docker Compose (bridge, `b2bcall_default`), +igual a `postgres` e `redis`. Nenhuma porta é publicada no host — nem 8021 +(ESL), nem SIP (5060/5080), nem RTP. Isso é intencional: ainda não existe +nenhum tronco SIP real nem ramal externo, então não há motivo pra expor nada. + +`apps/api` roda hoje **direto no host** (fora do Docker), então usa +`APP_DATABASE_URL`/`REDIS_URL` apontando pra `localhost` nas portas publicadas +pelo Postgres/Redis. Ela não consegue (nem precisa, ainda) alcançar o +FreeSWITCH. + +## Decisão pendente: `network_mode` do FreeSWITCH (agente.md secao 19) + +Quando existir um tronco SIP real (fase Trunks), será preciso decidir entre: + +- **`network_mode: host`**: mais simples pra SIP/RTP (sem NAT entre o + container e a rede), mas perde isolamento de rede do Docker. +- **macvlan/ipvlan**: dá ao FreeSWITCH um IP próprio na rede física, sem expor + outros serviços do host: mais trabalho de configurar, melhor isolamento. + +Não decidido ainda — só vira relevante quando houver um carrier/SBC real pra +conectar (secao 17: "FreeSWITCH não deverá depender de IP SIP público" — a +topologia esperada é `Internet → OpenSIPS → rede SIP privada → FreeSWITCH`, +então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece +manter bridge/macvlan em vez de host). + +## Quando `apps/api` virar container + +Hoje ela roda no host por conveniência de desenvolvimento. Quando virar o +serviço Docker `b2bcall-api` (agente.md secao 14), as connection strings +precisam trocar de `localhost` pros hostnames internos do compose +(`postgres`, `redis`, `freeswitch`) — ver nota em docs/AUTHENTICATION.md sobre +essa pegadinha. diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile new file mode 100644 index 0000000..3b7ee11 --- /dev/null +++ b/infrastructure/freeswitch/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.7 +# +# Imagem própria/controlada do FreeSWITCH (agente.md secao 19), usando os +# pacotes pré-compilados do SignalWire em vez de compilar da fonte — build +# de C/C++ e´ pesado demais pra VM de laboratorio (1.9GB RAM). +FROM debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates gnupg curl \ + && rm -rf /var/lib/apt/lists/* + +# FREESWITCH_PAT só existe durante este RUN (BuildKit secret, nunca vira +# camada da imagem) e o arquivo de credenciais do apt é apagado antes do fim +# do MESMO RUN — agente.md secao 11: "a credencial nao devera permanecer na +# imagem runtime". +RUN --mount=type=secret,id=freeswitch_pat,required=true \ + set -eu; \ + FS_PAT="$(cat /run/secrets/freeswitch_pat)"; \ + curl -fsSL -u "signalwire:${FS_PAT}" \ + https://freeswitch.signalwire.com/repo/deb/debian-release/signalwire-freeswitch-repo.gpg \ + -o /usr/share/keyrings/signalwire-freeswitch-repo.gpg; \ + install -d -m 700 /etc/apt/auth.conf.d; \ + printf 'machine freeswitch.signalwire.com\nlogin signalwire\npassword %s\n' "${FS_PAT}" \ + > /etc/apt/auth.conf.d/freeswitch.conf; \ + chmod 600 /etc/apt/auth.conf.d/freeswitch.conf; \ + echo "deb [signed-by=/usr/share/keyrings/signalwire-freeswitch-repo.gpg] https://freeswitch.signalwire.com/repo/deb/debian-release/ trixie main" \ + > /etc/apt/sources.list.d/freeswitch.list; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + freeswitch-meta-vanilla \ + freeswitch-conf-vanilla \ + freeswitch-mod-xml-curl \ + freeswitch-mod-callcenter \ + freeswitch-mod-avmd \ + freeswitch-mod-curl; \ + rm -f /etc/apt/auth.conf.d/freeswitch.conf /etc/apt/sources.list.d/freeswitch.list; \ + rm -rf /var/lib/apt/lists/* + +# Overrides mínimos por cima da config vanilla (agente.md secao 15: "carregar +# somente o necessário"). Directory/dialplan/sip_profiles ficam na config +# vanilla padrão por enquanto — serão substituídos por mod_xml_curl na fase +# "Extensions/Trunks/Dialplan" (ver docs/FREESWITCH.md). +COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_configs/modules.conf.xml +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 8021 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["/usr/bin/freeswitch", "-nonat"] diff --git a/infrastructure/freeswitch/entrypoint.sh b/infrastructure/freeswitch/entrypoint.sh new file mode 100644 index 0000000..e22f9e3 --- /dev/null +++ b/infrastructure/freeswitch/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Aplica secrets de RUNTIME (nunca de build — a imagem nao carrega nenhum +# valor real) antes de subir o FreeSWITCH. Falha alto se ESL_PASSWORD nao +# estiver definido: nunca cair silenciosamente na senha padrao "ClueCon" +# (agente.md secao 22). +set -eu + +: "${ESL_PASSWORD:?ESL_PASSWORD precisa estar definido no ambiente do container}" + +sed -i "s///" \ + /etc/freeswitch/autoload_configs/event_socket.conf.xml + +exec "$@" diff --git a/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml new file mode 100644 index 0000000..9e96e06 --- /dev/null +++ b/infrastructure/freeswitch/overrides/autoload_configs/modules.conf.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +