Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions docker-compose.egress-test.yml
Original file line number Diff line number Diff line change
@@ -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/tcp/127.0.0.1/3128' 2>/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/tcp/127.0.0.1/3128' 2>/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
2 changes: 1 addition & 1 deletion docker-compose.example.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: 9router-stack
name: 9rtksync-stack
Comment thread
elielsousa-pathbit marked this conversation as resolved.

services:
9router:
Expand Down
161 changes: 161 additions & 0 deletions docs/wiki/Egress-Testing.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
elielsousa-pathbit marked this conversation as resolved.
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 <gateway-container>

# 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.
1 change: 1 addition & 0 deletions docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/_Sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading