diff --git a/TODO.md b/TODO.md
index f525cce..b5cda1e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2456,6 +2456,74 @@ rota de entrada")
os dois persistiram com o tipo certo, sobrevivendo a um reload
completo da página.
+## PHASE 68 — Ramal registrado mas chamada não completava / sem áudio
+(reportado pelo usuário testando com 2 telefones IP reais, 1502 e 1503)
+- [x] **Achado 1 (chamada não completava, 503 instantâneo)**: os dois
+ ramais registravam com o Contact apontando pro MESMO IP público
+ compartilhado desta rede (esta VM nunca tem IP público próprio —
+ é sempre RFC1918 atrás do NAT do escritório). Ao originar uma
+ chamada, o FreeSWITCH tentava mandar o INVITE de volta pra esse
+ IP público — hairpin NAT clássico, o roteador do escritório não
+ sabe refletir esse tráfego de volta pra dentro. Corrigido com
+ `NDLB-received-in-nat-reg-contact` (Dockerfile) — o Contact salvo
+ passa a ser o IP/porta REALMENTE observados no pacote (a rede
+ interna), não o que o telefone auto-relata.
+- [x] `network_mode: host` no serviço `freeswitch` (pedido do usuário,
+ "sempre é problema NAT do Docker") — tira a camada de NAT/bridge
+ do Docker do meio. Quebra em cascata corrigida: FreeSWITCH sai da
+ rede bridge do compose, então (a) não resolve mais os OUTROS
+ containers pelo nome de serviço — `fs-config` virou
+ `http://127.0.0.1:8080/` (porta publicada só em loopback,
+ xml_curl.conf.xml) —, e (b) os workers que chamavam ESL via nome
+ `freeswitch` (fs-events/fs-config/predictive-dialer) passam a usar
+ `host.docker.internal` (+ `extra_hosts: host-gateway`, Docker
+ 20.10+). Segurança do Event Socket deixa de depender de publicar
+ só em loopback (não existe mais essa camada) e passa a depender
+ inteiramente da ACL `b2bcall_internal` já configurada
+ (`apply-inbound-acl`) — confirmado que continua rejeitando
+ qualquer origem fora de loopback/rede do Docker.
+- [x] **Achado 2 (chamada completava, mas sem áudio)**: com o registro
+ corrigido, a ligação passou a completar, mas sem RTP (confirmado
+ pelo usuário com `sngrep`). `local-network-acl="localnet.auto"`
+ (padrão vanilla) só cobre a subnet da PRÓPRIA interface do
+ FreeSWITCH — os ramais reais ficam em OUTRAS subnets do mesmo
+ escritório, então eram tratados como "de fora" e o SDP trocava o
+ RTP/SIP anunciado pelo IP público compartilhado de novo (mesmo
+ hairpin, agora na mídia). Corrigido com uma ACL própria
+ (`b2bcall_lan`, cobre todo RFC1918 — nunca alcançável de verdade
+ "de fora" nesta VM) referenciada em `local-network-acl`.
+- [x] **Achado 3 (chamada completava e tinha áudio, mas morria sozinha
+ em exatos 32s)**: mesmo com áudio já corrigido, toda chamada
+ morria sozinha depois de exatos 32 segundos — o Timer H do
+ RFC 3261 (64×T1, prazo padrão de qualquer UAS esperando o ACK de
+ um 2xx). Só descoberto de verdade com captura de pacote real
+ (`tcpdump`, instalado nesta sessão — não estava no host): o
+ `200 OK` que o FreeSWITCH manda pro ramal que RECEBEU a chamada
+ ainda tinha `Contact: sip:...@191.240.175.55:5060` (o IP público
+ de novo, MESMO com o SDP já mostrando o IP certo) — o telefone
+ nunca manda o ACK de volta (não tem como chegar por ali), o
+ FreeSWITCH retransmite o 200 OK sozinho (0.5s/1s/2s/4s/4s...) até
+ desistir e derrubar a chamada. `apply-nat-acl`/`local-network-acl`
+ (achados 1/2) não bastam aqui — são checagens independentes, e
+ trocar `apply-nat-acl` pra escopo `b2bcall_lan` (testado) não
+ mudou nada nesse Contact. O fix de verdade: `ext-rtp-ip`/
+ `ext-sip-ip` (STUN, resolvem pro IP público) REMOVIDOS do profile
+ `internal` (Dockerfile) — sem endereço "externo" nenhum
+ configurado, o FreeSWITCH não tem mais como escolher errado,
+ cai sempre em `rtp-ip`/`sip-ip` (10.10.32.138). Único profile
+ afetado; `external` (reservado pra um trunk PSTN real, nunca
+ usado ainda) mantém os dois params intactos.
+- [x] Diagnosticado ao vivo lendo `/var/log/freeswitch/freeswitch.log`
+ em tempo real durante as tentativas de chamada do usuário (nunca
+ supondo causa sem ver o log) e, pro achado 3, uma captura de
+ pacote real via `tcpdump` — cada achado confirmado por evidência
+ exata (`NORMAL_TEMPORARY_FAILURE`/503 instantâneo pro achado 1;
+ endereço do `c=`/RTP-IP pro achado 2; cabeçalho `Contact:` do
+ `200 OK` capturado byte a byte pro achado 3). Confirmado resolvido
+ pelo usuário com chamadas reais nos dois sentidos (1502→1503 e
+ 1503→1502), áudio bidirecional e chamada sobrevivendo bem além
+ dos 32s que travavam toda tentativa antes do achado 3.
+
---
## Riscos conhecidos
diff --git a/docker-compose.yml b/docker-compose.yml
index 02c4a47..7cd6a8d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -49,11 +49,18 @@ services:
FS_CONFIG_USER: ${FS_CONFIG_USER}
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
- ESL_HOST: freeswitch
+ # 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
@@ -63,7 +70,12 @@ services:
# 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
- # Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
+ # 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
@@ -99,28 +111,31 @@ services:
# uma chamada ativa seria latência/confiabilidade desnecessárias
# pra um prompt de poucos segundos.
- ./data/ivr-prompts:/ivr-prompts
- # Event Socket (8021) NUNCA publicado pra REDE — só em loopback do
- # próprio host (agente.md secao 22: "NÃO pública... acesso somente
- # pela aplicação autorizada"). Achado real testando as telas de
- # Infraestrutura (platform): o comentário original aqui dizia que
- # elas SEMPRE falhariam nesta VM porque "apps/api roda fora do
- # Docker" — isso estava incompleto. `apps/api` (systemd, no host)
- # JÁ conseguia alcançar o IP do container na rede bridge sem nenhuma
- # porta publicada (`docker inspect` + `/dev/tcp` confirmam: o HOST
- # sempre alcança a rede bridge do Docker, só outras MÁQUINAS é que
- # não). O bloqueio real era `ESL_HOST=freeswitch` — um nome DNS que
- # só existe dentro da rede interna do Docker, nunca no resolver do
- # host. `127.0.0.1:8021:8021` resolve isso sem abrir a porta pra
- # ninguém além do próprio host — mesma garantia de segurança de
- # antes, só que a aplicação autorizada agora alcança de verdade.
- # SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
- # pra rede a partir da PHASE 54 — necessário pra registrar um
- # softphone/telefone de fora da rede Docker.
- ports:
- - "127.0.0.1:8021:8021"
- - "5060:5060/udp"
- - "5060:5060/tcp"
- - "16384-16584:16384-16584/udp"
+ # 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
@@ -139,7 +154,9 @@ services:
- redis
- postgres
environment:
- ESL_HOST: freeswitch
+ # 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
@@ -159,6 +176,8 @@ services:
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
@@ -174,7 +193,9 @@ services:
- redis
- postgres
environment:
- ESL_HOST: freeswitch
+ # 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
@@ -188,6 +209,8 @@ services:
# — 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:
diff --git a/docs/NETWORK_ARCHITECTURE.md b/docs/NETWORK_ARCHITECTURE.md
index afae72d..206f4f9 100644
--- a/docs/NETWORK_ARCHITECTURE.md
+++ b/docs/NETWORK_ARCHITECTURE.md
@@ -1,31 +1,47 @@
# Arquitetura de Rede
-## Estado atual (fase FreeSWITCH inicial)
+## Estado atual (PHASE 68 — `network_mode: host`)
-`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.
+`freeswitch` roda em `network_mode: host` — decisão tomada na PHASE 68 (era
+"pendente" antes, ver seção abaixo), pedida pelo usuário depois de dois
+ramais reais no mesmo escritório não conseguirem se ligar. `postgres`/
+`redis`/`fs-config` continuam na rede bridge padrão do compose
+(`b2bcall_default`); só o FreeSWITCH saiu dela.
-`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.
+Com host networking, o FreeSWITCH usa a stack de rede do PRÓPRIO host
+diretamente — sem NAT/bridge do Docker no meio, `local_ip_v4` resolve pro IP
+real da interface (`10.10.32.138` nesta VM), SIP/ESL escutam direto nas
+interfaces do host. Isso elimina uma camada de tradução de endereço que
+existia antes de qualquer roteador físico entrar em cena — ver PHASE 68 no
+TODO.md pro relato completo dos 3 achados (registro, áudio, e a chamada
+morrendo sozinha em 32s) que motivaram e validaram essa mudança.
-## Decisão pendente: `network_mode` do FreeSWITCH (agente.md secao 19)
+Efeitos em cascata (o FreeSWITCH sai da rede bridge do compose):
+- Não resolve mais os outros containers pelo nome de serviço — `fs-config`
+ agora é alcançado via `http://127.0.0.1:8080/` (porta publicada só em
+ loopback, `xml_curl.conf.xml`).
+- Os workers que chamavam ESL via nome `freeswitch`
+ (fs-events/fs-config/predictive-dialer) usam `host.docker.internal` (+
+ `extra_hosts: host-gateway`, exige Docker 20.10+).
+- Segurança do Event Socket deixa de depender de "publicar só em loopback"
+ (não existe mais essa camada) e passa a depender inteiramente da ACL
+ `b2bcall_internal` já configurada (`apply-inbound-acl`) — confirmado que
+ continua rejeitando qualquer origem fora de loopback/rede do Docker.
-Quando existir um tronco SIP real (fase Trunks), será preciso decidir entre:
+`apps/api` continua rodando **direto no host** (fora do Docker) — nada muda
+pra ela, `ESL_HOST=127.0.0.1` já funcionava e continua funcionando (agora de
+forma ainda mais direta, sem a camada de publish-port do Docker no meio).
-- **`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.
+## Decisão tomada: `network_mode` do FreeSWITCH (agente.md secao 19)
-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).
+Historicamente listada como pendente (macvlan/ipvlan vs. host), resolvida na
+PHASE 68 a favor de **`network_mode: host`** — não por causa de um trunk
+PSTN real (a topologia `Internet → OpenSIPS → rede SIP privada →
+FreeSWITCH` da secao 17 continua valendo pra isso), mas por um problema
+concreto de NAT entre dois ramais registrados no mesmo escritório (ver
+achado abaixo). `macvlan`/`ipvlan` continuam como alternativas válidas se um
+dia o isolamento de rede do host virar prioridade maior que a simplicidade
+atual — não implementadas, não descartadas.
## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio
diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile
index 9b0d717..eb8cefa 100644
--- a/infrastructure/freeswitch/Dockerfile
+++ b/infrastructure/freeswitch/Dockerfile
@@ -78,6 +78,83 @@ RUN sed -i \
/etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
+# Hairpin NAT entre dois ramais atrás do MESMO roteador (PHASE 68, achado
+# real reportado pelo usuário: "1502 liga pra 1503 e não completa" —
+# ambos registrados, mas a chamada falhava em 0ms com 503). Confirmado
+# lendo `sofia status profile internal reg`: os dois ramais registram com
+# Contact apontando pro MESMO IP público desta rede (a VM nunca tem IP
+# público próprio — secao "essa máquina nunca terá IP público" — telefone
+# e servidor share o mesmo NAT do escritório). `apply-nat-acl` (nat.auto,
+# já testado funcionando) SÓ detecta que o cliente está atrás de NAT; sem
+# `NDLB-received-in-nat-reg-contact`, o Contact salvo continua sendo o que
+# o telefone auto-relatou (o IP público da rede) em vez do IP realmente
+# observado no pacote UDP (a rede interna, ex.: 192.168.40.165) — originar
+# uma chamada nova pro Contact salvo então tenta mandar o INVITE de volta
+# pro IP público, que o roteador do escritório não sabe refletir de volta
+# pra dentro (hairpin NAT clássico). Ligando este parâmetro, o Contact
+# salvo passa a ser o IP/porta REALMENTE observados — a chamada entre dois
+# ramais no mesmo escritório passa a rotear direto pela rede interna,
+# nunca saindo (e voltando) pelo roteador.
+RUN sed -i \
+ -e 's###' \
+ /etc/freeswitch/sip_profiles/internal.xml \
+ && grep -q '' /etc/freeswitch/sip_profiles/internal.xml
+
+# Continuação do achado acima: registro corrigido, mas a CHAMADA
+# completava sem áudio. `local-network-acl="localnet.auto"` (vanilla) só
+# cobre a subnet da própria interface do FreeSWITCH — os ramais reais
+# ficam em OUTRAS subnets do mesmo escritório, então eram tratados como
+# "de fora" e o SDP trocava o RTP/SIP anunciado pelo IP público
+# compartilhado (mesmo hairpin de antes, agora na mídia). `b2bcall_lan`
+# (acl.conf.xml, cobre todo RFC1918 — nunca alcançável de verdade "de
+# fora" nesta VM, que nunca tem IP público próprio) resolve isso.
+RUN sed -i \
+ -e 's###' \
+ /etc/freeswitch/sip_profiles/internal.xml \
+ && grep -q '' /etc/freeswitch/sip_profiles/internal.xml
+
+# Terceira camada do mesmo achado: com áudio corrigido, a chamada ainda
+# morria sozinha em exatos 32s (Timer H do RFC 3261) — o 200 OK que o
+# FreeSWITCH manda pro ramal que RECEBEU a chamada tinha
+# `Contact: sip:...@191.240.175.55:5060` (IP público de novo), então o
+# telefone nunca manda o ACK de volta (não tem como voltar por ali) e o
+# FreeSWITCH fica retransmitindo o 200 OK sozinho até desistir. Achado
+# via captura de pacote real (tcpdump), não suposição.
+#
+# `b2bcall_external_nat` (acl.conf.xml, sentido INVERTIDO de
+# `b2bcall_lan`: só marca como NAT'd quem está de verdade fora de todo
+# RFC1918 conhecido) escopa `apply-nat-acl` corretamente pra esta
+# implantação — mantido por ser mais correto e não quebrar o fix de
+# registro acima (testado: `NDLB-received-in-nat-reg-contact` não
+# depende de `apply-nat-acl` bater, continua funcionando mesmo sem o
+# `fs_nat=yes` marcado). MAS isto sozinho não resolveu o Contact do
+# 200 OK — confirmado com uma segunda captura de pacote depois desta
+# mudança, o Contact continuou com o IP público.
+RUN sed -i \
+ -e 's###' \
+ /etc/freeswitch/sip_profiles/internal.xml \
+ && grep -q '' /etc/freeswitch/sip_profiles/internal.xml
+
+# O fix de verdade pro Contact: `ext-rtp-ip`/`ext-sip-ip` (STUN,
+# `$${external_sip_ip}` = 191.240.175.55) fazem FreeSWITCH sempre ter
+# um endereço "público" pronto pra usar como identidade própria em
+# respostas SIP — independente de qualquer ACL de NAT, é só o valor
+# que o profile usa quando "se apresentando" externamente. Esta VM
+# NUNCA tem IP público próprio de verdade (confirmado pelo usuário) —
+# pro profile "internal" (só ramais registrados deste escritório,
+# nunca um trunk/SBC de verdade), esse endereço "externo" nunca é
+# correto, só existe pra causar exatamente este bug. Removendo os 2
+# params, o profile cai automaticamente pra `rtp-ip`/`sip-ip`
+# (10.10.32.138) pra tudo, sem exceção — não tem mais endereço "errado"
+# pro FreeSWITCH escolher. O profile "external" (reservado pra um
+# trunk PSTN real, nunca usado ainda nesta implantação) mantém
+# `ext-rtp-ip`/`ext-sip-ip` intactos — só o "internal" precisava disso.
+RUN sed -i \
+ -e '//d' \
+ -e '//d' \
+ /etc/freeswitch/sip_profiles/internal.xml \
+ && ! grep -q 'ext-rtp-ip\|ext-sip-ip' /etc/freeswitch/sip_profiles/internal.xml
+
# RTP num range fixo e pequeno (PHASE 54, docs/EXTENSIONS.md) — o range
# vanilla default (16384-32768, ~16k portas) é inviável de publicar uma a
# uma no host; achado ao tentar registrar um softphone de fora da rede
diff --git a/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml
index 3f2c7b7..d7d9eff 100644
--- a/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml
+++ b/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml
@@ -10,5 +10,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml
index e114c60..2f18104 100644
--- a/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml
+++ b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml
@@ -6,7 +6,13 @@
vanilla continua valendo como fallback quando "not found".
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
-
+
+