From 1865598959c5dad9a4a225681e5ddedd65f055ae Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 18:41:01 -0300 Subject: [PATCH] feat: acesso remoto documentado e cada gateway na sua porta no host **Portas.** O claudegravity dos artigos ocupa a 20128, que e a porta padrao do 9Router. As stacks dos repositorios saem dessa faixa para que tudo rode junto: 9Router em 8081, OmniRoute em 8082, LiteLLM em 8083; os paineis seguem em 9091, 9092 e 9093. Os exemplos de compose embutidos na documentacao foram sincronizados -- um exemplo que diverge do arquivo real e pior que exemplo nenhum, porque o leitor copia e fica com duas verdades. Novo teste: toda porta citada na documentacao tem de existir num compose deste repositorio. Verificado introduzindo 127.0.0.1:9999 numa copia. **Acesso remoto.** Pagina nova na wiki, Remote-Access, cobrindo tunel Cloudflare, Tailscale e proxy reverso -- com a ordem que importa: 1. exigir login e definir senha propria 2. exigir chave de API 3. so entao expor A ordem nao e preferencia. As stacks sobem com REQUIRE_LOGIN=false e REQUIRE_API_KEY=false, o que e seguro enquanto a porta esta presa em 127.0.0.1 -- e deixa de ser no instante em que o gateway ganha um endereco publico: - `/v1` e prefixo publico por projeto no 9Router (src/dashboardGuard.js), para que as ferramentas de codigo chamem o gateway sem sessao. Com chave de API desligada e URL publica, quem descobrir o endereco gasta as contas; - as rotas administrativas do gateway so sao protegidas quando requireLogin esta ligado. Com ele desligado, um gateway exposto entrega a propria configuracao; - o bloqueio do botao de tunel enquanto o login esta desligado vive na tela: o endpoint nao reavalia a condicao. A pagina tambem separa duas coisas que o Tailscale faz e que se confundem: ENTRAR (alcancar o gateway de fora, aqui) e SAIR (cada conta pelo seu endereco, em Egress-And-Multi-Session). Versoes proprias para OmniRoute e LiteLLM, que nao tem botao nativo: o caminho e cloudflared ou tailscale a mao, e no LiteLLM a protecao e a master key, nao uma flag de login. --- README.md | 14 +- docker-compose.example.yml | 5 +- docs/wiki/Home.md | 1 + docs/wiki/Installation.md | 14 +- docs/wiki/Remote-Access.md | 267 ++++++++++++++++++++++++++++++ docs/wiki/_Sidebar.md | 1 + tests/test_portas_documentadas.py | 63 +++++++ tools/valida_docs.py | 11 +- 8 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 docs/wiki/Remote-Access.md create mode 100644 tests/test_portas_documentadas.py diff --git a/README.md b/README.md index a5c7c34..034286a 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,17 @@ lado. O mesmo vale para os gateways: cada um tem a sua. | Serviço | Porta interna | Publicada no host | | :--- | :--- | :--- | -| 9Router | `20128` | `20128` | -| OmniRoute | `20128` | `20129` | -| LiteLLM | `4000` | `20130` | +| 9Router | `20128` | `8081` | +| OmniRoute | `20128` | `8082` | +| LiteLLM | `4000` | `8083` | | 9RTKSync (painel) | `9090` | `9091` | | OminiRTkSync (painel) | `9090` | `9092` | | LiteLlmRTKSync (painel) | `9090` | `9093` | +A stack dos artigos (`claudegravity`) fica com a **`20128`**, a porta padrão do +9Router. As stacks dos repositórios saem dessa faixa de propósito: assim você +roda o artigo e os três sincronizadores ao mesmo tempo, sem conflito. + Tudo preso a `127.0.0.1`: o gateway carrega credenciais reais e não deve ficar acessível na rede local. Para mudar qualquer uma, altere o lado esquerdo do mapeamento no compose — o lado direito é a porta interna, que o processo escuta. @@ -138,7 +142,9 @@ services: container_name: claudegravity-router restart: unless-stopped ports: - - "127.0.0.1:20128:20128" + # 20128 dentro do container; 8081 no host, para nao disputar a porta + # padrao do 9Router com a stack do artigo. + - "127.0.0.1:8081:20128" volumes: - 9router_data:/app/data healthcheck: diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 761485e..75555a0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -6,7 +6,10 @@ services: container_name: 9router restart: unless-stopped ports: - - "127.0.0.1:20128:20128" + # Porta interna 20128 (padrao do 9Router); publicada em 8081 no host. + # A 20128 do host fica para a stack do artigo (claudegravity), que usa + # a porta padrao -- assim as duas rodam juntas sem colidir. + - "127.0.0.1:8081:20128" environment: - DATA_DIR=/app/data - PORT=20128 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 04f808b..897c7e1 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -21,6 +21,7 @@ republishes these pages automatically. Editing a page directly here will be over | [Authentication](Authentication) | Credentials, headless mode, break-glass recovery | | [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 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/Installation.md b/docs/wiki/Installation.md index 63e86ef..cadd218 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -23,7 +23,9 @@ services: container_name: 9router restart: unless-stopped ports: - - "127.0.0.1:20128:20128" + # 20128 dentro do container; 8081 no host, para nao disputar a porta + # padrao do 9Router com a stack do artigo. + - "127.0.0.1:8081:20128" environment: - DATA_DIR=/app/data - PORT=20128 @@ -169,12 +171,16 @@ Same for the gateways. | Service | Inside | Published | | :--- | :--- | :--- | -| 9Router | `20128` | `20128` | -| OmniRoute | `20128` | `20129` | -| LiteLLM | `4000` | `20130` | +| 9Router | `20128` | `8081` | +| OmniRoute | `20128` | `8082` | +| LiteLLM | `4000` | `8083` | | 9RTKSync panel | `9090` | `9091` | | OminiRTkSync panel | `9090` | `9092` | | LiteLlmRTKSync panel | `9090` | `9093` | +The article stack (`claudegravity`) keeps **`20128`**, 9Router's default port. +The repository stacks stay out of that range on purpose, so you can run the +article and all three synchronizers at once without a conflict. + All bound to `127.0.0.1`: the gateway holds real credentials and should not be reachable from the local network. diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md new file mode 100644 index 0000000..4e924ea --- /dev/null +++ b/docs/wiki/Remote-Access.md @@ -0,0 +1,267 @@ +# Remote access: tunnel, Tailscale and what must come first + +*(Versão em português ao final.)* + +Reaching the gateway from another machine — your laptop away from home, a +teammate, a phone — has three usual answers. They differ in who can reach you, +and the order in which you turn things on decides whether that is safe. + +> **This page is about getting IN.** Sending traffic OUT through a chosen +> address, so each account has its own source IP, is a different problem with a +> different answer — see [Egress and Multi-Session](Egress-And-Multi-Session). +> Tailscale appears in both pages doing two unrelated jobs. + +--- + +## Read this before enabling anything + +The stacks in this repository ship with: + +```yaml +- REQUIRE_API_KEY=false +- REQUIRE_LOGIN=false +``` + +That is **safe while the port is bound to `127.0.0.1`**, which is how every +compose here publishes it: only your own machine can reach it, and demanding a +password from yourself on localhost adds friction without adding safety. + +The moment you expose the gateway, that reasoning inverts. 9Router's dashboard +says it in a yellow banner, and it is not decoration: + +> *Enable "Require login" and set a custom password before activating the tunnel.* + +Two things make this sharper than it looks: + +1. **`/v1` is a public prefix.** In `src/dashboardGuard.js`, the gateway lists its + own inference endpoints — `/v1`, `/v1beta`, `/api/v1`, `/codex`, `/responses` — + as public, because they are meant to be called by your coding tools without a + dashboard session. With `REQUIRE_API_KEY=false` and the gateway on a public URL, + **anyone who learns the URL can spend your accounts**. +2. **The gateway's admin routes fall open too.** Its `/api/settings`, `/api/keys`, + `/api/providers` and the rest of the gateway's admin surface are protected + *only when `requireLogin` is on*. + With it off, an exposed gateway hands over its own configuration. + +So the order is not a preference: + +``` +1. REQUIRE_LOGIN=true + a password you chose +2. REQUIRE_API_KEY=true + a key for your tools +3. only then, the tunnel or Tailscale +``` + +--- + +## Option 1 — Cloudflare tunnel (built into 9Router) + +The dashboard's **API Endpoint** screen has a `Tunnel` button. It registers a +Cloudflare quick tunnel and gives you a public `https://…trycloudflare.com` +address that reaches your gateway without opening any port on your router. + +**When it fits:** you need a URL reachable from anywhere, including devices you +do not control, and you accept that the address is public to whoever has it. + +**What to know:** + +- The URL is **public**. There is no allow-list — the only thing between the + internet and your accounts is `REQUIRE_LOGIN` and `REQUIRE_API_KEY`. +- The address changes every time the tunnel is re-enabled, unless you bring your + own named Cloudflare tunnel. +- The dashboard blocks the button while login is off — but that gate lives in the + screen. The gateway's own `POST /api/tunnel/enable` does not re-check it, so a script or an + extension can enable the tunnel while login is still off. Set the two flags + first and the question does not arise. + +--- + +## Option 2 — Tailscale (built into 9Router, and the one to prefer) + +The same screen has a `Tailscale` button, which installs and connects the +daemon. Your machine joins your private tailnet and the gateway becomes +reachable at a `100.x.y.z` address, or at a MagicDNS name like +`http://your-host:20128`. + +**When it fits:** almost always. Only devices you enrolled in your tailnet can +reach the gateway — the address is not public, and there is nothing for a +stranger to find. + +**Setting it up by hand**, which is also how you do it for OmniRoute and LiteLLM: + +```bash +# 1. On the host that runs the gateway +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up + +# 2. Find the address it received +tailscale ip -4 # e.g. 100.101.102.103 + +# 3. Publish the gateway on the tailnet interface instead of loopback +# (in the compose, replace 127.0.0.1 with the tailnet address) +ports: + - "100.101.102.103:20128:20128" + +# 4. From another device already in the tailnet +curl http://100.101.102.103:20128/v1/models +``` + +Binding to the tailnet address rather than `0.0.0.0` matters: `0.0.0.0` also +exposes the gateway to the local network — the café Wi-Fi, the office VLAN — +which is exactly what you were avoiding. + +**MagicDNS** makes this readable: with it on, `http://your-host:20128` works from +any device in the tailnet, and the address survives a change of IP. + +--- + +## Option 3 — your own reverse proxy + +A VPS with Caddy or nginx in front, TLS terminated there, Basic Auth or mTLS on +top. More work, and the only option that lets you put your own authentication +layer in front of the gateway instead of relying on its flags. + +Worth it when several people share one gateway and you want access logs and +revocation per person — neither of which the gateway's own login gives you. + +--- + +## Which one + +| | Tunnel | Tailscale | Reverse proxy | +| :--- | :--- | :--- | :--- | +| Who can reach it | anyone with the URL | only your tailnet | whoever you let through | +| Setup | one button | one button, or 3 commands | real work | +| Stable address | no (unless named) | yes (MagicDNS) | yes | +| Works for OmniRoute / LiteLLM | no built-in button | **yes**, same recipe | yes | +| Sensible default | for a one-off demo | **for everyday use** | shared or audited setups | + +--- + +## After exposing it, check what you exposed + +```bash +# From another device, WITHOUT credentials — both should refuse +curl -si https:///v1/models | head -1 # expect 401 +curl -si https:/// | head -1 # expect 401 or a login page + +# The synchronizer panel should not be exposed at all +curl -si https://:9091/ | head -1 # expect connection refused +``` + +The panel of this synchronizer has no reason to leave the machine: it reads the +gateway's database and shows credentials' health. Keep its port on `127.0.0.1` +and reach it through the same tunnel or tailnet you use for everything else. + +--- + +# Em português + +Alcançar o gateway de outra máquina tem três respostas usuais. Elas diferem em +**quem consegue chegar até você**, e a ordem em que você liga as coisas decide se +isso é seguro. + +> **Esta página é sobre entrar.** Fazer o tráfego **sair** por um endereço +> escolhido, para que cada conta tenha o seu IP, é outro problema — veja +> [Egress and Multi-Session](Egress-And-Multi-Session). O Tailscale aparece nas +> duas páginas fazendo trabalhos diferentes. + +## Leia antes de ligar qualquer coisa + +As stacks deste repositório sobem com `REQUIRE_API_KEY=false` e +`REQUIRE_LOGIN=false`. Isso é **seguro enquanto a porta está presa em +`127.0.0.1`** — só a sua máquina alcança, e exigir senha de si mesmo no +localhost acrescenta atrito sem acrescentar segurança. + +No instante em que o gateway é exposto, o raciocínio se inverte. O painel do +9Router avisa, e o aviso não é decorativo: + +> *Enable "Require login" and set a custom password before activating the tunnel.* + +Dois detalhes tornam isso mais sério do que parece: + +1. **`/v1` é prefixo público.** Em `src/dashboardGuard.js`, os endpoints de + inferência do gateway (`/v1`, `/v1beta`, `/api/v1`, `/codex`, `/responses`) + são públicos por projeto — é assim que as ferramentas de código chamam o gateway + sem sessão. Com `REQUIRE_API_KEY=false` e o gateway numa URL pública, + **qualquer um que descubra o endereço gasta as suas contas**. +2. **As rotas administrativas do gateway também caem.** As dele — `/api/settings`, + `/api/keys`, `/api/providers` e afins do gateway — só são protegidas **quando `requireLogin` está + ligado**. Com ele desligado, um gateway exposto entrega a própria + configuração. + +A ordem, portanto, não é preferência: + +``` +1. REQUIRE_LOGIN=true + uma senha escolhida por você +2. REQUIRE_API_KEY=true + uma chave para as suas ferramentas +3. só então, o túnel ou o Tailscale +``` + +## Opção 1 — túnel Cloudflare (nativo do 9Router) + +A tela **API Endpoint** tem o botão `Tunnel`. Ele registra um quick tunnel da +Cloudflare e devolve um endereço público `https://…trycloudflare.com` que +alcança o gateway sem abrir porta nenhuma no seu roteador. + +**Quando serve:** você precisa de uma URL alcançável de qualquer lugar, +inclusive de dispositivos que você não controla, e aceita que o endereço seja +público para quem o tiver. + +**O que saber:** a URL é pública e não há lista de permissão — entre a internet +e as suas contas existem apenas `REQUIRE_LOGIN` e `REQUIRE_API_KEY`. O endereço +muda a cada reativação, a menos que você use um túnel nomeado seu. E o bloqueio +do botão enquanto o login está desligado vive **na tela**: o `POST +/api/tunnel/enable` do gateway não reavalia a condição. Ligue as duas variáveis antes e a +questão não se coloca. + +## Opção 2 — Tailscale (nativo do 9Router, e o que preferir) + +A mesma tela tem o botão `Tailscale`, que instala e conecta o daemon. A sua +máquina entra na sua tailnet e o gateway passa a ser alcançável num endereço +`100.x.y.z`, ou num nome MagicDNS como `http://seu-host:20128`. + +**Quando serve:** quase sempre. Só os dispositivos que você cadastrou alcançam o +gateway — o endereço não é público e não há o que um estranho descubra. + +**Configurando à mão**, que é também como se faz no OmniRoute e no LiteLLM: + +```bash +# 1. No host que roda o gateway +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up + +# 2. Descubra o endereço recebido +tailscale ip -4 # ex.: 100.101.102.103 + +# 3. Publique o gateway na interface da tailnet, em vez do loopback +ports: + - "100.101.102.103:20128:20128" + +# 4. De outro dispositivo já na tailnet +curl http://100.101.102.103:20128/v1/models +``` + +Prender no endereço da tailnet em vez de `0.0.0.0` importa: `0.0.0.0` também +expõe o gateway à rede local — o Wi-Fi do café, a VLAN do escritório — que é +justamente o que se queria evitar. + +## Opção 3 — proxy reverso próprio + +Um VPS com Caddy ou nginx na frente, TLS terminado ali, Basic Auth ou mTLS por +cima. Dá mais trabalho, e é a única opção que permite colocar a **sua** camada +de autenticação na frente do gateway em vez de depender das flags dele. + +Compensa quando várias pessoas dividem um gateway e você quer log de acesso e +revogação por pessoa — coisas que o login do gateway não oferece. + +## Depois de expor, confira o que você expôs + +```bash +# De outro dispositivo, SEM credencial — os dois têm de recusar +curl -si https:///v1/models | head -1 # espera-se 401 +curl -si https:/// | head -1 # espera-se 401 ou tela de login +``` + +O painel deste sincronizador não tem motivo para sair da máquina: ele lê o banco +do gateway e mostra a saúde das credenciais. Mantenha a porta dele em +`127.0.0.1` e alcance-o pelo mesmo túnel ou tailnet que você já usa. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 4a928b8..3a310c9 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -8,6 +8,7 @@ - [Logging](Logging) - [Architecture](Architecture) - [Egress and Multi-Session](Egress-And-Multi-Session) +- [Remote Access](Remote-Access) - [Troubleshooting](Troubleshooting) - [Upstream Fixes](Upstream-Fixes) diff --git a/tests/test_portas_documentadas.py b/tests/test_portas_documentadas.py new file mode 100644 index 0000000..9b6cdc5 --- /dev/null +++ b/tests/test_portas_documentadas.py @@ -0,0 +1,63 @@ +"""As portas citadas na documentação têm de ser as que o compose publica. + +Um exemplo de compose embutido no README que diverge do arquivo real é pior que +exemplo nenhum: o leitor copia, sobe, e fica com duas verdades — a que está no +texto e a que está na stack. Foi o que aconteceu quando as portas mudaram e os +exemplos ficaram para trás. + +Este teste lê as portas que os composes do repositório realmente publicam e +exige que todo bloco `- "127.0.0.1:X:Y"` citado na documentação seja um deles. +""" + +import os +import re +import unittest + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RX_PORTA = re.compile(r'-\s*"127\.0\.0\.1:(\d+):(\d+)"') + + +def composes(): + for nome in sorted(os.listdir(RAIZ)): + if nome.startswith("docker-compose") and nome.endswith((".yml", ".yaml")): + yield os.path.join(RAIZ, nome) + + +def documentacao(): + for base in (RAIZ, os.path.join(RAIZ, "docs", "wiki")): + if not os.path.isdir(base): + continue + for nome in sorted(os.listdir(base)): + if nome.endswith(".md"): + yield os.path.join(base, nome) + + +def mapeamentos(caminho): + with open(caminho, encoding="utf-8") as f: + return set(RX_PORTA.findall(f.read())) + + +class TestPortasDocumentadasBatem(unittest.TestCase): + def test_every_port_mapping_in_the_docs_matches_a_compose(self): + reais = set() + for c in composes(): + reais |= mapeamentos(c) + self.assertTrue(reais, "nenhum mapeamento de porta encontrado nos composes") + + divergentes = [] + for doc in documentacao(): + for host, interna in mapeamentos(doc): + if (host, interna) not in reais: + divergentes.append( + f"{os.path.relpath(doc, RAIZ)}: 127.0.0.1:{host}:{interna} " + f"não corresponde a nenhum compose deste repositório" + ) + self.assertEqual( + divergentes, + [], + "documentação e compose discordam:\n " + "\n ".join(divergentes), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/valida_docs.py b/tools/valida_docs.py index 7c7f485..70cf31e 100644 --- a/tools/valida_docs.py +++ b/tools/valida_docs.py @@ -34,7 +34,7 @@ # Linha que invoca outro programa: as flags citadas pertencem a ele. RX_COMANDO_DE_TERCEIRO = re.compile( - r"\b(pip|pip3|docker|docker[- ]compose|git|curl|wget|tailscale|make|npm|npx|" + r"\b(pip|pip3|docker|docker[- ]compose|git|curl|wget|tailscale|cloudflared|make|npm|npx|" r"apt|apt-get|brew|systemctl|python3?\s+-m\s+venv|openssl|psql)\b" ) @@ -155,8 +155,13 @@ def verificar(raiz: str, nome: str) -> List[str]: if flag not in flags_ok: problemas.append(f"{nome}/{rel}:{n} flag citada e inexistente no CLI: {flag}") - # Uma rota citada numa frase sobre o gateway e do gateway. - if RX_ROTA_DO_GATEWAY.search(linha): + # Uma rota citada numa frase sobre o gateway e do gateway. A frase + # pode estar quebrada em varias linhas -- prosa com margem de 80 + # colunas quebra no meio o tempo todo -- entao vale o paragrafo, e + # nao a linha isolada: olhar so a linha acusava uma rota do gateway + # sempre que a palavra "gateway" tinha caido na linha de cima. + contexto = "".join(linhas[max(0, n - 3):n + 1]) + if RX_ROTA_DO_GATEWAY.search(contexto): continue for rota in RX_ROTA.findall(linha):