Usuário reportou que 1502 ligando pra 1503 (dois telefones IP reais, ambos registrados) não completava a chamada. Três bugs em camadas diferentes, cada um só confirmado lendo o log/capturando pacote real — nunca por suposição: 1. Registro: os dois ramais registravam com Contact apontando pro MESMO IP público compartilhado desta rede (a VM nunca tem IP público próprio, é sempre RFC1918 atrás do NAT do escritório) — originar uma chamada tentava mandar o INVITE de volta pra esse IP público, hairpin NAT clássico, falha instantânea (503). Fix: NDLB-received-in-nat-reg- contact (Contact salvo vira o IP realmente observado no pacote). De quebra, aplicado network_mode: host no serviço freeswitch (pedido explícito do usuário) — tira o Docker NAT/bridge do meio. Quebra em cascata corrigida: fs-config vira alcançável só via 127.0.0.1:8080 (não mais nome de serviço), workers ESL (fs-events/predictive-dialer) passam a usar host.docker.internal. 2. Áudio: com o registro corrigido, a chamada completava mas sem RTP — local-network-acl="localnet.auto" só cobria a subnet da própria interface do FreeSWITCH, tratando ramais de OUTRAS subnets do mesmo escritório como "de fora" e trocando o SDP pelo IP público de novo. Fix: ACL própria (b2bcall_lan, cobre todo RFC1918) referenciada em local-network-acl. 3. Chamada morrendo sozinha em exatos 32s (Timer H do RFC 3261): mesmo com áudio ok, o 200 OK que o FreeSWITCH manda pro ramal que recebeu a chamada ainda tinha Contact com o IP público — o telefone nunca manda o ACK de volta, FreeSWITCH retransmite sozinho até desistir. Só confirmado com tcpdump (instalado nesta sessão) capturando o pacote byte a byte. Fix: ext-rtp-ip/ext-sip-ip (STUN, sempre resolvem pro IP público) removidos do profile "internal" — sem endereço "externo" configurado, o FreeSWITCH nunca mais tem como escolher errado. Confirmado resolvido pelo usuário com chamadas reais nos dois sentidos, áudio bidirecional, sobrevivendo bem além dos 32s que travavam antes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
249 lines
10 KiB
YAML
249 lines
10 KiB
YAML
services:
|
|
postgres:
|
|
image: postgres:18-alpine
|
|
container_name: b2bcall-postgres
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
ports:
|
|
- "127.0.0.1:5432:5432"
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
container_name: b2bcall-redis
|
|
restart: unless-stopped
|
|
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
|
|
ports:
|
|
- "127.0.0.1:6379:6379"
|
|
volumes:
|
|
- redis_data:/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep -q PONG"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
fs-config:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/freeswitch-config/Dockerfile
|
|
container_name: b2bcall-fs-config
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- postgres
|
|
- redis
|
|
environment:
|
|
# Hostname interno do compose (postgres/redis/freeswitch), nao
|
|
# localhost — ver docs/NETWORK_ARCHITECTURE.md.
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
|
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
|
# host.docker.internal (PHASE 68): freeswitch virou network_mode:
|
|
# host, saiu da rede bridge do compose — o nome de serviço
|
|
# "freeswitch" não resolve mais pra ele. host.docker.internal
|
|
# aponta pro HOST (onde o ESL agora escuta direto), exige o
|
|
# extra_hosts abaixo em Linux (Docker 20.10+).
|
|
ESL_HOST: host.docker.internal
|
|
ESL_PORT: "8021"
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
|
CALLCENTER_QUEUES_DIR: /callcenter-queues
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
volumes:
|
|
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
|
# escreve os XML de gateway aqui, o profile "external" ja inclui
|
|
# sip_profiles/external/*.xml automaticamente (config vanilla).
|
|
- freeswitch_external_gateways:/gateways
|
|
# Compartilhado com o FreeSWITCH (agente.md secao 50-51): fs-config
|
|
# escreve um XML de fila por arquivo aqui, incluido via
|
|
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
|
|
- freeswitch_callcenter_queues:/callcenter-queues
|
|
# Publicado só em loopback (PHASE 68): o FreeSWITCH virou
|
|
# network_mode: host e chama isto via 127.0.0.1 agora (ver
|
|
# xml_curl.conf.xml) — antes não precisava de porta nenhuma porque os
|
|
# dois estavam na mesma rede bridge do compose.
|
|
ports:
|
|
- "127.0.0.1:8080:8080"
|
|
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
|
|
secrets:
|
|
- freeswitch_pat
|
|
container_name: b2bcall-freeswitch
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- fs-config
|
|
environment:
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
|
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
|
volumes:
|
|
- freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external
|
|
- freeswitch_callcenter_queues:/etc/freeswitch/autoload_configs/callcenter_queues.conf.d
|
|
# Bind mount (não volume nomeado do Docker) porque apps/api roda no
|
|
# host e precisa enxergar os mesmos arquivos que o FreeSWITCH grava
|
|
# (agente.md secao 90-91) — ver docs/RECORDING.md.
|
|
- ./data/recordings-spool:/recordings
|
|
# Prompts de áudio do IVR (PHASE 59) — mesmo raciocínio do bind
|
|
# mount acima, mas na direção contrária: apps/api (host) ESCREVE o
|
|
# arquivo que o usuário sobe, e o FreeSWITCH precisa LER esse mesmo
|
|
# arquivo ao vivo durante `play_and_get_digits`. Só funciona como
|
|
# bind mount de disco local — um fetch em rede (S3/HTTP) durante
|
|
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
|
# pra um prompt de poucos segundos.
|
|
- ./data/ivr-prompts:/ivr-prompts
|
|
# network_mode: host (PHASE 68) — achado real reportado pelo usuário:
|
|
# dois ramais reais (telefones IP), atrás do MESMO roteador/NAT desta
|
|
# rede (esta VM nunca tem IP público próprio — é sempre RFC1918 por
|
|
# trás do NAT do escritório), ligando um pro outro. Com bridge+NAT do
|
|
# Docker no meio, o SIP/RTP entre eles precisava atravessar uma camada
|
|
# de tradução de endereço A MAIS antes mesmo de chegar no roteador do
|
|
# escritório — combinado com o hairpin NAT do roteador (dois clientes
|
|
# atrás do mesmo NAT se enxergando só pelo IP público), a chamada
|
|
# falhava em 0ms (503, nunca chegava a sair pela rede de verdade).
|
|
# Tirar o Docker NAT do meio (host networking) elimina essa camada
|
|
# extra; o fix real de hairpin em si é separado — ver comentário sobre
|
|
# `NDLB-received-in-nat-reg-contact` no Dockerfile.
|
|
#
|
|
# Sem `ports:` aqui (inválido com network_mode: host — o container usa
|
|
# as portas do HOST diretamente). Segurança do Event Socket (8021,
|
|
# agente.md secao 22) deixa de depender de publicar só em loopback
|
|
# (não existe mais essa camada) e passa a depender inteiramente da
|
|
# ACL `apply-inbound-acl="b2bcall_internal"` já configurada em
|
|
# event_socket.conf.xml (127.0.0.0/8 + 172.16.0.0/12, o range do
|
|
# bridge do Docker) — qualquer outra origem continua rejeitada mesmo
|
|
# com a porta agora "aberta" nas interfaces do host. Os workers que
|
|
# antes alcançavam via nome de serviço `freeswitch` na rede do compose
|
|
# (fs-events/fs-config/predictive-dialer) precisam agora de
|
|
# `host.docker.internal` — ver `extra_hosts` em cada um deles abaixo.
|
|
network_mode: host
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 20s
|
|
|
|
fs-events:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/freeswitch-events/Dockerfile
|
|
container_name: b2bcall-fs-events
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- freeswitch
|
|
- redis
|
|
- postgres
|
|
environment:
|
|
# host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
|
|
# equivalente em fs-config acima.
|
|
ESL_HOST: host.docker.internal
|
|
ESL_PORT: "8021"
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
# Hostnames internos do compose (freeswitch/redis), diferente do
|
|
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
|
|
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
|
# Gravações (agente.md secao 90-93) — mesmo padrão do REDIS_URL
|
|
# acima: path Docker-interno aqui, path do host no .env pro
|
|
# apps/api enxergar os mesmos arquivos (ver docs/RECORDING.md).
|
|
RECORDINGS_SPOOL_DIR: /recordings
|
|
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
|
|
LOCAL_STORAGE_ROOT: /data/object-storage
|
|
S3_BUCKET: ${S3_BUCKET:-}
|
|
S3_REGION: ${S3_REGION:-}
|
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
volumes:
|
|
- ./data/recordings-spool:/recordings
|
|
- ./data/object-storage-local:/data/object-storage
|
|
|
|
predictive-dialer:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/predictive-dialer/Dockerfile
|
|
container_name: b2bcall-predictive-dialer
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- freeswitch
|
|
- redis
|
|
- postgres
|
|
environment:
|
|
# host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
|
|
# equivalente em fs-config acima.
|
|
ESL_HOST: host.docker.internal
|
|
ESL_PORT: "8021"
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
|
# Real Outbound Safety (agente.md secao 186): as DUAS precisam estar
|
|
# explicitamente ligadas pra originar PSTN de verdade — nunca ativado
|
|
# por default. Ver docs/PREDICTIVE_DIALER.md.
|
|
DIALER_SIMULATION: ${DIALER_SIMULATION:-true}
|
|
ALLOW_REAL_OUTBOUND_CALLS: ${ALLOW_REAL_OUTBOUND_CALLS:-false}
|
|
# Só usado pra montar o path do record_session (agente.md secao 91)
|
|
# — quem de fato grava e' o processo FreeSWITCH, não este worker;
|
|
# não precisa do volume montado aqui, só saber o path Docker-interno.
|
|
RECORDINGS_SPOOL_DIR: /recordings
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
|
|
ai-worker:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/ai-worker/Dockerfile
|
|
container_name: b2bcall-ai-worker
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- postgres
|
|
environment:
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
# Precisa ler as gravações que fs-events já subiu (agente.md secao
|
|
# 104-108) — mesmo par de env vars/bind mount de fs-events, ver
|
|
# docs/RECORDING.md e docs/AI_PIPELINE.md.
|
|
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
|
|
LOCAL_STORAGE_ROOT: /data/object-storage
|
|
S3_BUCKET: ${S3_BUCKET:-}
|
|
S3_REGION: ${S3_REGION:-}
|
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
|
|
volumes:
|
|
- ./data/object-storage-local:/data/object-storage
|
|
|
|
secrets:
|
|
freeswitch_pat:
|
|
environment: FREESWITCH_PAT
|
|
|
|
volumes:
|
|
postgres_data:
|
|
redis_data:
|
|
freeswitch_external_gateways:
|
|
freeswitch_callcenter_queues:
|