From 401eaa0e1b9397394d96835d778d0af54ce78bda Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 16:41:58 -0300 Subject: [PATCH] docs: a pagina de saida de rede descrevia o gateway errado O Egress-And-Multi-Session.md deste repositorio era copia literal do OminiRTkSync: mencionava OmniRoute oito vezes contra cinco do 9Router, e o "como configurar" mandava o leitor cadastrar a saida em Settings -> Proxies do OmniRoute, com scope=account e proxy_enabled -- campos que nao existem aqui. Quem seguisse o passo a passo nao chegava a lugar nenhum. Reescrito com o 9Router em primeiro plano, lido do fonte do gateway (src/lib/network/connectionProxy.js e open-sse/utils/proxyFetch.js), nao deduzido. Dois achados que nenhuma versao registrava: - **Pool inutilizavel nao da erro.** resolveConnectionProxyConfig() exige isActive === true E proxyUrl preenchido; faltando qualquer um, a conexao cai em silencio para os campos legados e, se eles estiverem vazios, aquela conta volta a dividir o endereco do host. Vincular um pool nao basta. - **Sem strictProxy, o isolamento some no primeiro erro de rede.** Quando o proxy falha, o proxyFetch registra um aviso e REFAZ a requisicao direto. A conta que se quis isolar sai pelo IP do host, e o unico rastro e um console.warn. Com strictProxy, a requisicao falha em vez de vazar. Tambem: pools do tipo vercel/cloudflare/deno nao sao proxy HTTP, e sim relay por reescrita de URL -- o resolvedor devolve connectionProxyEnabled: false de proposito e expoe o endereco como vercelRelayUrl. A consulta SQL de verificacao foi reescrita para o schema real (JSON em providerConnections.data) e testada contra o gateway em execucao: classifica as sete conexoes e distingue pool ausente, inativo e sem URL. Novo teste: a documentacao do /healthz tem de citar exatamente as respostas que o servidor produz, e nao pode inventar nenhuma. --- docs/Egress-And-Multi-Session.md | 276 ++++++++++++++++++------------ tests/test_healthz_documentado.py | 76 ++++++++ 2 files changed, 247 insertions(+), 105 deletions(-) create mode 100644 tests/test_healthz_documentado.py diff --git a/docs/Egress-And-Multi-Session.md b/docs/Egress-And-Multi-Session.md index 765d392..044bbed 100644 --- a/docs/Egress-And-Multi-Session.md +++ b/docs/Egress-And-Multi-Session.md @@ -7,94 +7,124 @@ is not the number of sessions — it is **how they look from the outside**. When gateway fans many accounts out through a single machine, every one of those accounts shows the same source address, and the pattern is what draws attention. -This page documents what the gateway already gives you to control that, and what -the synchronizer shows about it. Every schema detail below was read from the running `decolua/9router` and -`diegosouzapw/omniroute` images — each section names the gateway it describes. -Nothing here is a claim about any provider's policy, which we cannot verify and -do not restate. +This page documents what **9Router** already gives you to control that, and what +the synchronizer shows about it. Every field below was read from the running +`decolua/9router` image and from its source — `src/lib/network/connectionProxy.js` +and `open-sse/utils/proxyFetch.js`. Nothing here is a claim about any provider's +policy, which we cannot verify and do not restate. --- -## What OmniRoute already models +## What 9Router models -Three tables, all present in the shipped schema: +The egress endpoints live in the **`proxyPools`** table and the binding lives +**inside the connection**, under `providerSpecificData`: -| Table | What it holds | +| Field | What it does | | --- | --- | -| `proxy_registry` | The egress endpoints: `host`, `port`, `username`, `password`, `region`, `country_code`, plus measured `latency_ms`, `quality_score`, `anonymity` and `google_access`. | -| `proxy_assignments` | The binding: `proxy_id`, `scope`, `scope_id`, `position`. Unique on `(scope, scope_id, proxy_id)`. | -| `proxy_scope_rotation` | Per scope: `strategy`, `cursor`, `sticky_window_minutes`, `rotated_at`. | +| `proxyPoolId` | Which pool this connection egresses through. The literal `"__none__"` means *explicitly disabled*. | +| `connectionProxyEnabled` | The legacy per-connection switch. | +| `connectionProxyUrl` | The legacy per-connection address, used when no pool resolves. | +| `connectionNoProxy` | Hosts to reach directly, bypassing the proxy. | + +### How the address is actually chosen + +`resolveConnectionProxyConfig()` decides, in this order: -`scope` takes three values in the code: **`global`**, **`provider`** and -**`account`**. Two more switches live on the connection row itself: -`provider_connections.proxy_enabled` and `per_key_proxy_enabled`. +1. **`proxyPoolId` set and the pool is usable** — the pool row must have + `isActive === true` *and* a non-empty `proxyUrl`. The pool's URL wins. +2. **Otherwise it falls back to the legacy fields** on the connection + (`connectionProxyEnabled` + `connectionProxyUrl`). +3. **Otherwise no proxy at all** — the request leaves through the host's own + address. -### The property that matters +Two consequences worth knowing before you rely on this: -The selector short-circuits when a scope resolves to exactly one proxy — with a -single entry it is returned directly, without consulting rotation at all. +> **A pool that is inactive, deleted or has an empty `proxyUrl` does not raise +> an error.** The connection silently falls back to the legacy fields, and if +> those are empty, that account goes back to sharing the host's address. Binding +> a pool is not enough — the pool has to be usable. -That gives you the arrangement you want with no extra machinery: +> **Pools of type `vercel`, `cloudflare` or `deno` are not HTTP proxies.** They +> are relays applied by rewriting the base URL, and the resolver deliberately +> returns `connectionProxyEnabled: false` for them, exposing the address as +> `vercelRelayUrl` instead. It takes precedence over the proxy path at request +> time. -> **One proxy assigned at `scope='account'` for a given connection id = that -> account always egresses from that address.** +### `strictProxy`: the difference between isolation and best effort -Where rotation *is* involved, `sticky_window_minutes` keeps the same choice for -a window; the stored default when a scope row is created is **30 minutes**. -Rotation is the opposite of what you want per account: it makes one account -appear from several addresses over time. +Each pool carries a `strictProxy` flag, and it decides what happens when the +proxy fails: + +- **`strictProxy: true`** — the request fails: `Proxy required but failed`. +- **`strictProxy` unset or false** — `proxyFetch` logs a warning and **retries + the request directly**, without the proxy. + +That fallback is the one that undoes the whole arrangement: the account you +isolated goes out through the host's address at the first network hiccup, and +the only trace is a `console.warn` in the gateway log. **If the point of the +binding is to keep accounts apart, set `strictProxy` on the pool.** ### How to set it up -Through OmniRoute's own screens and API — the synchronizer never writes here: +Through 9Router's own screens and API — the synchronizer never writes here: -1. Register each egress under **Settings → Proxies** (`/api/settings/proxies`). -2. Bind one to the connection, choosing the **account** scope, so the assignment - lands with `scope='account'` and `scope_id` equal to the connection id. -3. Turn on `proxy_enabled` for that connection. -4. Leave exactly **one** proxy in that scope. One entry means no rotation. +1. Register each egress as a pool, with its `proxyUrl` and `isActive` on. +2. Set `strictProxy` on the pool, unless you would rather leak than fail. +3. On the connection, set `providerSpecificData.proxyPoolId` to that pool. +4. Give each account its **own** pool. Two accounts on one pool share an + address, which is the situation you are trying to leave. Verification, without leaving the box: ```sql -SELECT c.name, - COALESCE(NULLIF(r.name,''), r.host || ':' || r.port) AS egress -FROM provider_connections c -LEFT JOIN proxy_assignments a ON a.scope = 'account' AND a.scope_id = c.id -LEFT JOIN proxy_registry r ON r.id = a.proxy_id +SELECT c.name AS account, + CASE + WHEN COALESCE(NULLIF(json_extract(c.data,'$.providerSpecificData.proxyPoolId'),''),'__none__') = '__none__' + THEN CASE WHEN json_extract(c.data,'$.providerSpecificData.connectionProxyEnabled') = 1 + AND NULLIF(json_extract(c.data,'$.providerSpecificData.connectionProxyUrl'),'') IS NOT NULL + THEN 'legacy (URL on the connection)' + ELSE 'SHARED (leaves through the host)' END + WHEN p.id IS NULL THEN 'pool missing -> SHARED' + WHEN p.isActive = 0 THEN 'pool inactive -> falls back to legacy' + WHEN NULLIF(json_extract(p.data,'$.proxyUrl'),'') IS NULL + THEN 'pool has no URL -> falls back to legacy' + ELSE 'bound' + END AS situation +FROM providerConnections c +LEFT JOIN proxyPools p + ON p.id = json_extract(c.data,'$.providerSpecificData.proxyPoolId') ORDER BY c.name; ``` -Any row with `egress` NULL shares the host's address with every other such row. +Every row reading `SHARED` leaves through the same address as the others. --- -## What 9Router models +## What the synchronizer shows -9Router keeps the pools in the `proxyPools` table and the binding **inside the -connection**, under `providerSpecificData`: +The panel surfaces the binding **read-only**, per connection: -- `proxyPoolId` — which pool this connection egresses through; -- `connectionProxyEnabled` — the per-connection switch, which must be `true`. +- **own egress** — the account has its own pool, and the panel names it; +- **shares the gateway address with N accounts** — no usable binding. The + warning only appears from the **second** such account onwards: one account + alone is the only owner of that address, and there is nothing to flag; +- **egress unknown** — the installation predates the proxy fields. -Same principle, different storage: the binding belongs to the connection rather -than to a separate assignment table. +The synchronizer never creates, edits or deletes a pool or a binding. It reads, +and it tells you what it found. Ownership of the routing stays with the gateway, +the only component that can actually apply it to a request. --- -## What the synchronizer shows - -The panel surfaces the binding **read-only**, per connection: - -- **bound** — the account has its own egress, and the panel names it; -- **shared** — no binding; this account leaves through the same address as the - rest; -- **unknown** — the installation is older than the proxy tables. +## The sibling gateway, for comparison -The synchronizer never creates, edits or deletes a proxy, an assignment or a -rotation strategy. It reads, and it tells you what it found. Ownership of the -routing stays with the gateway, which is the only component that can actually -apply it to a request. +[OminiRTkSync](https://github.com/pathbit/OminiRTkSync) documents the same idea +for OmniRoute, which stores it differently: a `proxy_registry` of endpoints, +a `proxy_assignments` table binding them by `scope` (`global`, `provider`, +`account`) and a `proxy_scope_rotation` row per scope. There, a single proxy at +`scope='account'` pins that account's address. Same principle, different +storage — see that repository's copy of this page. --- @@ -109,12 +139,11 @@ the binding above. accounts on one gateway, they all still share it. An exit node alone does not separate accounts. - **Several exit nodes** do separate them, but only if each account is bound to - a different one — and the binding is exactly the `scope='account'` assignment - described above. Tailscale supplies the addresses; OmniRoute decides which - account uses which. + a different one — and the binding is exactly the `proxyPoolId` above. + Tailscale supplies the addresses; 9Router decides which account uses which. - `--advertise-exit-node` plus `--exit-node` are per-host settings. To route - per-account you still need one HTTP/SOCKS endpoint per node, registered in - `proxy_registry` like any other egress. + per-account you still need one HTTP/SOCKS endpoint per node, registered as its + own pool like any other egress. The same holds for any provider of addresses — a VPS, a residential proxy, a second uplink. The part that separates accounts is the per-account binding, not @@ -124,12 +153,11 @@ the technology that produced the address. - One egress per account when several accounts of the same provider live on one gateway. +- `strictProxy` on, so a failing proxy fails loudly instead of leaking. - Prefer **stability over rotation** for a named account: an account whose address changes every few minutes looks less like a person, not more. - Keep egress and account in the same region when the provider is - region-sensitive; `proxy_registry.country_code` is there for that. -- Check `google_access` on the registry row before binding a Google-backed - connection to it — the column exists because not every egress can reach it. + region-sensitive. --- @@ -140,55 +168,92 @@ problema não é a quantidade de sessões, e sim **como elas aparecem de fora**: quando um gateway distribui várias contas por uma única máquina, todas saem pelo mesmo endereço, e é esse padrão que chama atenção. -Esta página documenta o que o gateway **já oferece** para controlar isso e o que -o sincronizador mostra a respeito. Cada detalhe de schema foi lido das imagens `decolua/9router` e -`diegosouzapw/omniroute` em execução — cada seção diz de qual gateway trata. -Não há aqui nenhuma afirmação sobre política de fornecedor, que não temos como -verificar. +Esta página documenta o que o **9Router** já oferece para controlar isso e o que +o sincronizador mostra a respeito. Cada campo foi lido da imagem +`decolua/9router` em execução e do seu código — `src/lib/network/connectionProxy.js` +e `open-sse/utils/proxyFetch.js`. Não há aqui nenhuma afirmação sobre política de +fornecedor, que não temos como verificar. -## O que o OmniRoute já modela +## O que o 9Router modela -- `proxy_registry` — os endereços de saída (host, porta, usuário, senha, região, - `country_code`, `google_access`, latência e pontuação de qualidade). -- `proxy_assignments` — o vínculo: `proxy_id`, `scope`, `scope_id`, `position`. -- `proxy_scope_rotation` — por escopo: `strategy`, `cursor`, - `sticky_window_minutes`, `rotated_at`. +As saídas ficam na tabela **`proxyPools`** e o vínculo vive **dentro da +conexão**, em `providerSpecificData`: -Os escopos no código são **`global`**, **`provider`** e **`account`**. Na própria -linha da conexão existem ainda `proxy_enabled` e `per_key_proxy_enabled`. +| Campo | Para que serve | +| --- | --- | +| `proxyPoolId` | Por qual pool a conexão sai. O literal `"__none__"` significa *desativado explicitamente*. | +| `connectionProxyEnabled` | O interruptor legado, por conexão. | +| `connectionProxyUrl` | O endereço legado, usado quando nenhum pool resolve. | +| `connectionNoProxy` | Destinos a alcançar direto, sem passar pelo proxy. | -**A propriedade que importa:** quando um escopo resolve para exatamente um -proxy, o seletor devolve esse proxy direto, sem consultar rotação. Ou seja: +### Como o endereço é escolhido de fato -> **Um proxy vinculado em `scope='account'` para o id de uma conexão = aquela -> conta sai sempre pelo mesmo endereço.** +O `resolveConnectionProxyConfig()` decide nesta ordem: -Onde há rotação, `sticky_window_minutes` mantém a escolha por uma janela — o -padrão gravado ao criar a linha do escopo é de **30 minutos**. Rotação é o -oposto do que se quer por conta: faz uma conta aparecer de vários endereços. +1. **`proxyPoolId` definido e pool utilizável** — a linha do pool precisa ter + `isActive === true` **e** um `proxyUrl` não vazio. Vence a URL do pool. +2. **Senão, cai nos campos legados** da conexão (`connectionProxyEnabled` + + `connectionProxyUrl`). +3. **Senão, sem proxy nenhum** — a requisição sai pelo endereço do próprio host. -**Como configurar** (pelas telas e pela API do próprio OmniRoute; o -sincronizador não escreve nada disso): +Duas consequências que vale conhecer antes de confiar nisso: -1. Cadastre cada saída em **Settings → Proxies**. -2. Vincule uma à conexão escolhendo o escopo **account**. -3. Ligue `proxy_enabled` naquela conexão. -4. Deixe **um único** proxy nesse escopo — um só significa sem rotação. +> **Um pool inativo, apagado ou com `proxyUrl` vazio não gera erro.** A conexão +> cai silenciosamente para os campos legados e, se eles estiverem vazios, aquela +> conta volta a dividir o endereço do host. Vincular um pool não basta — ele +> precisa estar utilizável. -## O que o 9Router modela +> **Pools do tipo `vercel`, `cloudflare` ou `deno` não são proxy HTTP.** São +> relays aplicados por reescrita da URL base, e o resolvedor devolve +> deliberadamente `connectionProxyEnabled: false` para eles, expondo o endereço +> como `vercelRelayUrl`. Na hora do envio, o relay tem precedência sobre o +> caminho de proxy. + +### `strictProxy`: a diferença entre isolamento e melhor esforço + +Cada pool carrega a bandeira `strictProxy`, e ela decide o que acontece quando o +proxy falha: + +- **`strictProxy: true`** — a requisição falha: `Proxy required but failed`. +- **`strictProxy` ausente ou falso** — o `proxyFetch` registra um aviso e + **refaz a requisição direto**, sem o proxy. -Os pools ficam em `proxyPools` e o vínculo vive **dentro da conexão**, em -`providerSpecificData`: `proxyPoolId` diz por qual pool ela sai, e -`connectionProxyEnabled` precisa estar `true`. Mesmo princípio, armazenamento -diferente. +Essa queda para conexão direta é o que desfaz o arranjo inteiro: a conta que +você isolou sai pelo endereço do host no primeiro soluço de rede, e o único +rastro é um `console.warn` no log do gateway. **Se o propósito do vínculo é +manter as contas separadas, ligue `strictProxy` no pool.** + +### Como configurar + +Pelas telas e pela API do próprio 9Router; o sincronizador não escreve nada +disso: + +1. Cadastre cada saída como um pool, com `proxyUrl` preenchido e `isActive`. +2. Ligue `strictProxy` no pool, a menos que prefira vazar a falhar. +3. Na conexão, aponte `providerSpecificData.proxyPoolId` para esse pool. +4. Dê a cada conta o **seu próprio** pool. Duas contas no mesmo pool dividem + endereço, que é justamente a situação da qual se quer sair. + +A consulta de verificação está na seção em inglês, acima — vale igual. ## O que o painel mostra -Somente leitura, por conexão: **vinculada** (com o nome da saída), -**compartilhada** (sai junto com as outras) ou **desconhecida** (instalação -anterior às tabelas de proxy). O sincronizador nunca cria, edita nem apaga -proxy, vínculo ou estratégia — quem manda no roteamento é o gateway, o único que -consegue de fato aplicá-lo a uma requisição. +Somente leitura, por conexão: **saída própria** (com o nome do pool), **divide o +endereço do gateway com N contas** (sem vínculo utilizável — e o aviso só +aparece a partir da segunda conta nessa situação, porque uma sozinha é a única +dona daquele endereço) ou **saída desconhecida** (instalação anterior aos campos +de proxy). O sincronizador nunca cria, edita nem apaga pool ou vínculo — quem +manda no roteamento é o gateway, o único que consegue de fato aplicá-lo a uma +requisição. + +## O gateway irmão, para comparação + +O [OminiRTkSync](https://github.com/pathbit/OminiRTkSync) documenta a mesma +ideia para o OmniRoute, que a guarda de outro jeito: um `proxy_registry` de +endereços, uma tabela `proxy_assignments` que os vincula por `scope` (`global`, +`provider`, `account`) e uma linha de `proxy_scope_rotation` por escopo. Lá, um +único proxy em `scope='account'` fixa o endereço daquela conta. Mesmo princípio, +armazenamento diferente. ## Tailscale: o que resolve e o que não resolve @@ -198,16 +263,17 @@ consegue de fato aplicá-lo a uma requisição. mesmo gateway, todas continuam compartilhando. Exit node sozinho não separa contas. - **Vários exit nodes** separam, desde que cada conta esteja vinculada a um - deles — e o vínculo é exatamente o `scope='account'` acima. O Tailscale - fornece os endereços; o OmniRoute decide qual conta usa qual. + deles — e o vínculo é exatamente o `proxyPoolId` acima. O Tailscale fornece os + endereços; o 9Router decide qual conta usa qual. - Para rotear por conta você ainda precisa de um endpoint HTTP/SOCKS por nó, - cadastrado em `proxy_registry` como qualquer outra saída. + cadastrado como pool próprio, como qualquer outra saída. Vale o mesmo para qualquer origem de endereços — um VPS, um proxy residencial, um segundo link. O que separa contas é o vínculo por conta, não a tecnologia que produziu o endereço. **Padrões razoáveis:** uma saída por conta quando várias contas do mesmo -provedor dividem um gateway; preferir **estabilidade a rotação** para conta -nomeada; manter saída e conta na mesma região quando o provedor for sensível a -isso; e conferir `google_access` antes de vincular uma conexão do Google. +provedor dividem um gateway; `strictProxy` ligado, para que um proxy com +problema falhe alto em vez de vazar; preferir **estabilidade a rotação** para +conta nomeada; e manter saída e conta na mesma região quando o provedor for +sensível a isso. diff --git a/tests/test_healthz_documentado.py b/tests/test_healthz_documentado.py new file mode 100644 index 0000000..8d32a5a --- /dev/null +++ b/tests/test_healthz_documentado.py @@ -0,0 +1,76 @@ +"""A documentação do /healthz tem de citar as respostas que o código produz. + +A wiki anunciava `OMNIROUTE_SERVICE_UNREACHABLE` e o servidor respondia +`GATEWAY_SERVICE_UNREACHABLE`. Nada quebra: o painel funciona, o healthcheck +funciona, os testes passam. Quem perde é quem monta um alerta sobre a string +documentada — ele nunca dispara, e o silêncio parece saúde. + +Este teste lê as respostas direto do fonte do servidor e exige que cada uma +apareça na documentação. +""" + +import os +import re +import unittest + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SERVIDOR = os.path.join(RAIZ, "src", "nine_rtksync", "web", "server.py") + + +def respostas_do_healthz() -> set: + """Os literais que o /healthz devolve, lidos do fonte.""" + with open(SERVIDOR, encoding="utf-8") as f: + fonte = f.read() + inicio = fonte.find("def serve_healthz") + assert inicio > 0, "serve_healthz nao encontrada" + trecho = fonte[inicio:inicio + 1500] + return set(re.findall(r'b"([A-Z][A-Z0-9_]+)"', trecho)) + + +def paginas_de_documentacao(): + for pasta, dirs, arquivos in os.walk(RAIZ): + dirs[:] = [d for d in dirs if d not in (".git", "tmp", "node_modules", "__pycache__")] + for a in arquivos: + if a.endswith(".md"): + yield os.path.join(pasta, a) + + +class TestHealthzDocumentado(unittest.TestCase): + def test_every_answer_the_code_returns_is_documented(self): + respostas = respostas_do_healthz() + self.assertTrue(respostas, "nenhuma resposta encontrada no fonte") + + documentado = "" + for p in paginas_de_documentacao(): + with open(p, encoding="utf-8") as f: + documentado += f.read() + + faltando = sorted(r for r in respostas if r not in documentado) + self.assertEqual( + faltando, + [], + "o /healthz responde isto e a documentacao nao cita: " + ", ".join(faltando), + ) + + def test_no_page_invents_an_answer_the_code_never_returns(self): + respostas = respostas_do_healthz() + inventadas = [] + # O sublinhado precisa ser aceito no meio: OMNIROUTE_SERVICE_UNREACHABLE + # tem um segmento entre o prefixo e o sufixo, e um padrao que pare no + # primeiro sublinhado deixa passar justamente o caso que motivou o teste. + padrao = re.compile(r"`([A-Z][A-Z0-9_]*_(?:NOT_READY|UNREACHABLE|UNAVAILABLE))`") + for p in paginas_de_documentacao(): + with open(p, encoding="utf-8") as f: + for n, linha in enumerate(f, 1): + for citado in padrao.findall(linha): + if citado not in respostas: + inventadas.append(os.path.relpath(p, RAIZ) + ":" + str(n) + " " + citado) + self.assertEqual( + inventadas, + [], + "documentacao cita resposta que o codigo nunca devolve: " + ", ".join(inventadas), + ) + + +if __name__ == "__main__": + unittest.main()