From 2c6325e1ce5102cb2c38517f863942920d629d39 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 19:03:42 -0300 Subject: [PATCH] feat: bancada que prova por onde o trafego sai Vincular uma conta a um proxy e facil de configurar e dificil de verificar. O painel mostra o vinculo, o teste do pool diz OK, e nada disso responde a pergunta que importa: quando o proxy cai, o gateway para -- ou sai em silencio pelo endereco da propria maquina, carregando o token da conta? A bancada responde isso empiricamente, sem tocar a internet: dois proxies (para distinguir "saiu por um proxy" de "saiu por ESTE proxy") e um arbitro que devolve o endereco de origem que enxergou. E o `seen_from` do arbitro que torna o resultado verificavel, em vez de palpite ou servico de terceiro. tools/testa_saida_de_rede.sh cobre cinco cenarios; o quinto e o unico que separa isolamento de aparencia de isolamento: com o proxy fora do ar, a requisicao TEM de falhar. Se ainda for atendida, saiu direto. Medido contra um 9Router em execucao: - o vinculo do pool funciona -- o log do proxy registrou `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, onde 172.31.0.2 e o container do gateway; - o caminho de TESTE do pool detecta um proxy morto: "Proxy test timed out". O segundo resultado e tranquilizador do jeito errado, e por isso esta na documentacao: o teste do pool responde certo, entao o operador conclui que esta protegido -- enquanto o caminho de chat descarta a flag strictProxy antes de chegar a camada de fetch. Reportado em decolua/9router#4007. A licao que a pagina registra: teste o caminho que carrega o seu trafego, nao o que o painel oferece. Nao sao o mesmo codigo. Tambem nesta rodada: o nome do projeto das stacks passa a ser o do NOSSO produto (9rtksync-stack, ominirtksync-stack, litellmrtksync-test) -- antes dois levavam o nome do gateway, o que fazia a stack parecer do upstream. --- docker-compose.egress-test.yml | 105 +++++++++++++++++++++ docker-compose.example.yml | 2 +- docs/wiki/Egress-Testing.md | 161 +++++++++++++++++++++++++++++++++ docs/wiki/Home.md | 1 + docs/wiki/_Sidebar.md | 1 + tools/testa_saida_de_rede.sh | 149 ++++++++++++++++++++++++++++++ tools/valida_docs.py | 5 + 7 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 docker-compose.egress-test.yml create mode 100644 docs/wiki/Egress-Testing.md create mode 100755 tools/testa_saida_de_rede.sh diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml new file mode 100644 index 0000000..c7a596b --- /dev/null +++ b/docker-compose.egress-test.yml @@ -0,0 +1,105 @@ +name: egress-test + +# Bancada para testar POR ONDE o trafego sai. +# +# A pergunta que ela responde e uma so, e nao da para responder por leitura de +# codigo: quando uma conta esta vinculada a um proxy, a requisicao sai mesmo por +# ele? E quando esse proxy cai, o gateway falha -- ou sai direto, pelo IP da +# maquina, com o token da conta? +# +# Nada aqui toca a internet. O destino e um servidor local que devolve o IP de +# origem que enxergou, entao a resposta e verificavel e nao depende de servico +# de terceiro nem de sorte de rede. +# +# docker compose -f docker-compose.egress-test.yml up -d +# tools/testa_saida_de_rede.sh +# docker compose -f docker-compose.egress-test.yml down -v + +services: + # Dois proxies, nao um: com um so nao da para distinguir "saiu pelo proxy" de + # "saiu por qualquer proxy". Com dois, cada conta tem o seu, e o teste mostra + # se o vinculo por conta realmente separa as saidas. + proxy-a: + image: ubuntu/squid:latest + container_name: egress-proxy-a + restart: unless-stopped + ports: + - "127.0.0.1:18081:3128" + networks: + egress: + ipv4_address: 172.31.0.11 + healthcheck: + # O que importa e a porta aceitar conexao: squidclient nao existe nesta + # imagem, e um healthcheck que depende de binario ausente fica eternamente + # "starting" sem que nada esteja errado. + test: ["CMD-SHELL", "timeout 2 bash -c '/dev/null || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + + proxy-b: + image: ubuntu/squid:latest + container_name: egress-proxy-b + restart: unless-stopped + ports: + - "127.0.0.1:18082:3128" + networks: + egress: + ipv4_address: 172.31.0.12 + healthcheck: + # O que importa e a porta aceitar conexao: squidclient nao existe nesta + # imagem, e um healthcheck que depende de binario ausente fica eternamente + # "starting" sem que nada esteja errado. + test: ["CMD-SHELL", "timeout 2 bash -c '/dev/null || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + + # Destino: devolve, em JSON, o endereco de origem que enxergou. E o arbitro do + # teste -- e ele quem diz se a requisicao chegou pelo proxy A, pelo B ou + # direto da maquina. + echo: + image: python:3.12-alpine + container_name: egress-echo + restart: unless-stopped + ports: + - "127.0.0.1:18080:8080" + networks: + egress: + ipv4_address: 172.31.0.20 + command: + - python3 + - -c + - | + import json + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + class Echo(BaseHTTPRequestHandler): + def do_GET(self): + corpo = json.dumps({ + "seen_from": self.client_address[0], + "path": self.path, + "via": self.headers.get("Via", ""), + "forwarded_for": self.headers.get("X-Forwarded-For", ""), + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(corpo))) + self.end_headers() + self.wfile.write(corpo) + + def log_message(self, *a): + pass + + ThreadingHTTPServer(("0.0.0.0", 8080), Echo).serve_forever() + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/',timeout=2)\""] + interval: 5s + timeout: 3s + retries: 10 + +networks: + egress: + ipam: + config: + - subnet: 172.31.0.0/24 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 75555a0..82a95fc 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,4 +1,4 @@ -name: 9router-stack +name: 9rtksync-stack services: 9router: diff --git a/docs/wiki/Egress-Testing.md b/docs/wiki/Egress-Testing.md new file mode 100644 index 0000000..ea1a33a --- /dev/null +++ b/docs/wiki/Egress-Testing.md @@ -0,0 +1,161 @@ +# Testing where the traffic actually leaves from + +*(Versão em português ao final.)* + +Binding an account to a proxy is easy to configure and hard to verify. The +dashboard shows the binding, the pool test says OK, and none of that answers the +question that matters: **when the proxy goes down, does the gateway stop — or +does it quietly go direct, through the machine's own address, carrying the +account's token?** + +The difference between those two answers is the difference between isolation and +the appearance of isolation. This page is a bench that answers it empirically. + +--- + +## The bench + +```bash +docker compose -f docker-compose.egress-test.yml up -d +tools/testa_saida_de_rede.sh +docker compose -f docker-compose.egress-test.yml down -v +``` + +Three containers, none of which touch the internet: + +| Container | Address | Role | +| :--- | :--- | :--- | +| `egress-proxy-a` | `172.31.0.11` | an HTTP proxy | +| `egress-proxy-b` | `172.31.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | +| `egress-echo` | `172.31.0.20` | the referee: answers with the source address it saw | + +The referee is what makes this verifiable. It returns JSON: + +```json +{"seen_from": "172.31.0.11", "via": "1.1 squid/6.13", "forwarded_for": "172.31.0.2"} +``` + +`seen_from` is the whole point — no guessing, no third-party IP service, no +dependency on network luck. + +## What the script checks + +1. **Baseline** — with no proxy, the referee sees the machine's address. +2. **Through a proxy** — the referee sees the *proxy's* address instead. +3. **Two proxies, two addresses** — what makes one-account-per-address possible. +4. **Proxy down** — the request must **fail**. If it still succeeds, it went + direct, and that is the silent leak. +5. **Proxy back** — the egress returns with it. + +Step 4 is the only one that separates isolation from its appearance. + +## Pointing it at a real gateway + +The script as shipped drives `curl`, which verifies the bench itself. To measure +the **gateway's** decision, configure the proxy in the gateway and let it make +the request: + +```bash +# 1. put the gateway on the bench network +docker network connect egress-test_egress + +# 2. register the pool through the gateway's own API +curl -s -X POST http://127.0.0.1:8081/api/proxy-pools \ + -H 'Content-Type: application/json' \ + -d '{"name":"bench","proxyUrl":"http://172.31.0.11:3128","isActive":true,"strictProxy":true}' + +# 3. bind it to a connection, then watch the proxy log while traffic flows +docker logs -f egress-proxy-a +``` + +A line like `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the +gateway's container address going through the proxy — the binding works. + +Then stop the proxy and send traffic again. **If the request still succeeds, the +gateway fell back to direct.** + +## What was measured here + +Against a running 9Router (read on 2026-09-12): + +- the pool binding works: `docker logs egress-proxy-a` showed + `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.31.0.2` is the + gateway's container; +- the **pool test path** detects a dead proxy correctly: + `{"ok":false,"error":"Proxy test timed out"}`. + +That second result is worth pausing on, because it is reassuring in a misleading +way. The pool test says the right thing, so the operator concludes they are +covered — while the **chat path** drops the `strictProxy` flag before it reaches +the fetch layer and falls back to direct on failure. Reported upstream as +[decolua/9router#4007](https://github.com/decolua/9router/issues/4007). + +So: **test the path that carries your traffic, not the one the dashboard offers +you.** They are not the same code. + +--- + +# Em português + +Vincular uma conta a um proxy é fácil de configurar e difícil de verificar. O +painel mostra o vínculo, o teste do pool diz OK, e nada disso responde à pergunta +que importa: **quando o proxy cai, o gateway para — ou sai em silêncio pelo +endereço da própria máquina, carregando o token da conta?** + +A diferença entre essas duas respostas é a diferença entre isolamento e aparência +de isolamento. Esta página é uma bancada que responde isso empiricamente. + +## A bancada + +```bash +docker compose -f docker-compose.egress-test.yml up -d +tools/testa_saida_de_rede.sh +docker compose -f docker-compose.egress-test.yml down -v +``` + +Três containers, nenhum deles tocando a internet: dois proxies (para distinguir +"saiu por *um* proxy" de "saiu por *este* proxy") e um **árbitro**, que devolve +em JSON o endereço de origem que enxergou. É o `seen_from` que torna o resultado +verificável, sem palpite e sem depender de serviço de terceiro. + +## O que o script verifica + +1. **Linha de base** — sem proxy, o árbitro vê o endereço da máquina. +2. **Com proxy** — o árbitro vê o endereço *do proxy*. +3. **Dois proxies, dois endereços** — o que sustenta uma conta por endereço. +4. **Proxy fora do ar** — a requisição tem de **falhar**. Se ainda for atendida, + ela saiu direto, e esse é o vazamento silencioso. +5. **Proxy de volta** — a saída volta com ele. + +O passo 4 é o único que separa isolamento de aparência de isolamento. + +## Apontando para um gateway real + +O script, como vem, dirige o `curl` — isso verifica a bancada. Para medir a +decisão **do gateway**, configure o proxy nele e deixe-o fazer a requisição: +conecte o container do gateway à rede da bancada, cadastre o pool pela API dele, +vincule a uma conexão e acompanhe `docker logs -f egress-proxy-a`. + +Uma linha como `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o +endereço do container do gateway passando pelo proxy — o vínculo funciona. + +Depois derrube o proxy e gere tráfego de novo. **Se a requisição ainda for +atendida, o gateway caiu para saída direta.** + +## O que foi medido aqui + +Contra um 9Router em execução (lido em 12/09/2026): + +- o vínculo do pool funciona: o log do proxy registrou + `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`; +- o **caminho de teste do pool** detecta um proxy morto corretamente: + `{"ok":false,"error":"Proxy test timed out"}`. + +O segundo resultado merece atenção justamente por ser tranquilizador do jeito +errado. O teste do pool responde certo, então o operador conclui que está +protegido — enquanto o **caminho de chat** descarta a flag `strictProxy` antes de +chegar à camada de fetch e cai para saída direta quando o proxy falha. Reportado +em [decolua/9router#4007](https://github.com/decolua/9router/issues/4007). + +Ou seja: **teste o caminho que carrega o seu tráfego, não o que o painel +oferece.** Não são o mesmo código. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 897c7e1..3702d7c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -22,6 +22,7 @@ republishes these pages automatically. Editing a page directly here will be over | [Logging](Logging) | Persistent file log, rotation, 30-day retention | | [Architecture](Architecture) | How the sync engine talks to the 9Router database | | [Remote Access](Remote-Access) | Tunnel, Tailscale, and what has to be on before either | +| [Egress Testing](Egress-Testing) | A bench that proves where the traffic actually leaves from | | [Egress and Multi-Session](Egress-And-Multi-Session) | Why several accounts sharing one outbound address is the risk, and what the gateway models | | [Troubleshooting](Troubleshooting) | Concrete symptoms and what they actually mean | | [Upstream Fixes](Upstream-Fixes) | Bugs found in the gateways and the patches sent upstream | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 3a310c9..cfca41a 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -9,6 +9,7 @@ - [Architecture](Architecture) - [Egress and Multi-Session](Egress-And-Multi-Session) - [Remote Access](Remote-Access) +- [Egress Testing](Egress-Testing) - [Troubleshooting](Troubleshooting) - [Upstream Fixes](Upstream-Fixes) diff --git a/tools/testa_saida_de_rede.sh b/tools/testa_saida_de_rede.sh new file mode 100755 index 0000000..ed5a3af --- /dev/null +++ b/tools/testa_saida_de_rede.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Testa POR ONDE o trafego de um gateway sai. +# +# A pergunta central nao se responde lendo codigo: quando uma conta esta +# vinculada a um proxy e esse proxy cai, o gateway FALHA -- ou sai direto, pelo +# IP da maquina, com o token da conta? A diferenca entre as duas respostas e a +# diferenca entre isolamento e aparencia de isolamento. +# +# Como funciona: o destino e um servidor local que devolve o endereco de origem +# que enxergou. Nao ha palpite -- ele diz se a requisicao chegou pelo proxy A, +# pelo B ou direto. Nada toca a internet. +# +# Uso: +# docker compose -f docker-compose.egress-test.yml up -d +# tools/testa_saida_de_rede.sh +# +# Variaveis (todas com padrao): +# ECHO_URL destino visto de dentro da rede de teste (172.31.0.20:8080) +# PROXY_A proxy A, do host (127.0.0.1:18081) +# PROXY_B proxy B, do host (127.0.0.1:18082) +# IP_HOST o que o destino ve numa saida direta (172.31.0.1) +# IP_PROXY_A / IP_PROXY_B enderecos dos proxies (172.31.0.11/.12) + +set -uo pipefail + +ECHO_URL="${ECHO_URL:-http://172.31.0.20:8080}" +ECHO_HOST="${ECHO_HOST:-http://127.0.0.1:18080}" +PROXY_A="${PROXY_A:-http://127.0.0.1:18081}" +PROXY_B="${PROXY_B:-http://127.0.0.1:18082}" +IP_HOST="${IP_HOST:-172.31.0.1}" +IP_PROXY_A="${IP_PROXY_A:-172.31.0.11}" +IP_PROXY_B="${IP_PROXY_B:-172.31.0.12}" + +TOTAL=0 +FALHAS=0 + +ok() { TOTAL=$((TOTAL+1)); printf ' ok %-56s %s\n' "$1" "${2:-}"; } +falha() { TOTAL=$((TOTAL+1)); FALHAS=$((FALHAS+1)); printf ' FALHA %-56s %s\n' "$1" "${2:-}"; } + +# Devolve o campo seen_from que o destino enxergou, ou vazio se nao respondeu. +origem_vista() { # origem_vista + local proxy="$1" caminho="$2" resposta + if [ -n "$proxy" ]; then + resposta=$(curl -s --max-time 8 -x "$proxy" "$ECHO_URL$caminho" 2>/dev/null) + else + resposta=$(curl -s --max-time 8 "$ECHO_HOST$caminho" 2>/dev/null) + fi + printf '%s' "$resposta" | sed -n 's/.*"seen_from": *"\([^"]*\)".*/\1/p' +} + +echo "====================================================================" +echo " Por onde o trafego sai" +echo "====================================================================" + +echo +echo "-- 1. a bancada responde? --" +for nome in echo proxy-a proxy-b; do + case "$nome" in + echo) alvo="$ECHO_HOST" ;; + proxy-a) alvo="$PROXY_A" ;; + proxy-b) alvo="$PROXY_B" ;; + esac + if curl -s -o /dev/null --max-time 5 --connect-timeout 3 "$alvo" 2>/dev/null \ + || nc -z "${alvo#http://}" 2>/dev/null \ + || curl -s -o /dev/null --max-time 5 -x "$alvo" "$ECHO_URL/ping" 2>/dev/null; then + ok "$nome responde" + else + falha "$nome responde" "suba a bancada: docker compose -f docker-compose.egress-test.yml up -d" + fi +done + +echo +echo "-- 2. linha de base: sem proxy, o destino ve o endereco da maquina --" +visto=$(origem_vista "" "/direto") +if [ "$visto" = "$IP_HOST" ]; then + ok "saida direta vista como $IP_HOST" +else + falha "saida direta vista como $IP_HOST" "obtido=${visto:-}" +fi + +echo +echo "-- 3. com proxy, o destino ve o endereco DO PROXY, nao o da maquina --" +visto=$(origem_vista "$PROXY_A" "/via-a") +if [ "$visto" = "$IP_PROXY_A" ]; then + ok "pelo proxy A, visto como $IP_PROXY_A" +else + falha "pelo proxy A, visto como $IP_PROXY_A" "obtido=${visto:-}" +fi + +echo +echo "-- 4. proxies distintos produzem enderecos distintos --" +echo " (e o que permite uma conta por endereco; com um proxy so, nao da" +echo " para distinguir 'saiu pelo proxy' de 'saiu por qualquer proxy')" +a=$(origem_vista "$PROXY_A" "/conta-a") +b=$(origem_vista "$PROXY_B" "/conta-b") +if [ -n "$a" ] && [ -n "$b" ] && [ "$a" != "$b" ]; then + ok "conta A ($a) e conta B ($b) saem por enderecos diferentes" +else + falha "conta A e conta B saem por enderecos diferentes" "A=${a:-<->} B=${b:-<->}" +fi + +echo +echo "-- 5. A PERGUNTA QUE IMPORTA: com o proxy fora do ar, o que acontece? --" +echo " Se a requisicao ainda for atendida, ela saiu DIRETO -- pelo IP da" +echo " maquina, com o token da conta. E o vazamento silencioso." +if docker ps --format '{{.Names}}' | grep -qx egress-proxy-b; then + docker stop egress-proxy-b >/dev/null 2>&1 + sleep 2 + visto=$(origem_vista "$PROXY_B" "/proxy-morto") + if [ -z "$visto" ]; then + ok "proxy fora do ar -> a requisicao FALHA" "nenhuma resposta, como deve ser" + elif [ "$visto" = "$IP_HOST" ]; then + falha "proxy fora do ar -> a requisicao FALHA" \ + "VAZOU: saiu direto, visto como $IP_HOST" + else + falha "proxy fora do ar -> a requisicao FALHA" "resposta inesperada de $visto" + fi + docker start egress-proxy-b >/dev/null 2>&1 + sleep 3 +else + falha "proxy fora do ar -> a requisicao FALHA" "egress-proxy-b nao esta na bancada" +fi + +echo +echo "-- 6. o proxy volta e a saida volta com ele --" +visto=$(origem_vista "$PROXY_B" "/depois-de-voltar") +if [ "$visto" = "$IP_PROXY_B" ]; then + ok "proxy de volta, visto como $IP_PROXY_B" +else + falha "proxy de volta, visto como $IP_PROXY_B" "obtido=${visto:-}" +fi + +echo +echo "====================================================================" +printf ' verificacoes: %s falhas: %s\n' "$TOTAL" "$FALHAS" +echo "====================================================================" +echo +echo "Leitura do resultado:" +echo " - o passo 5 e o unico que distingue isolamento de aparencia de" +echo " isolamento. Um 'ok' ali significa que a queda do proxy interrompe o" +echo " trafego em vez de desviá-lo pelo endereco da maquina." +echo " - o passo 4 e o que sustenta uma conta por endereco: sem ele, todas as" +echo " contas compartilham a mesma saida, que e o padrao que chama atencao." +echo +echo "Ao ligar isso num gateway real, aponte ECHO_URL para o destino que o" +echo "gateway alcanca e configure o proxy NO GATEWAY -- nao no curl. O que se" +echo "quer medir e a decisao do gateway, nao a do cliente." + +exit $((FALHAS > 0)) diff --git a/tools/valida_docs.py b/tools/valida_docs.py index 70cf31e..929ddc3 100644 --- a/tools/valida_docs.py +++ b/tools/valida_docs.py @@ -140,6 +140,11 @@ def verificar(raiz: str, nome: str) -> List[str]: for var in RX_ENV.findall(linha): if var in NAO_SAO_VARIAVEIS or RUIDO.match(var): continue + # Codigo de log, nao variavel: TCP_TUNNEL/200, HIER_DIRECT/1.2.3.4 + # e NONE_NONE/000 aparecem em trecho de log colado na pagina, e + # sempre com uma barra logo depois. + if re.search(re.escape(var) + r"/", linha): + continue if var not in env_ok: problemas.append(f"{nome}/{rel}:{n} variável citada e não usada no código: {var}")