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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update host-facing URLs to use the new port

When this compose is used from the host, the gateway now listens on 8081, but NEXT_PUBLIC_BASE_URL immediately below and the repository's local ROUTER_URL defaults still point to 127.0.0.1:20128. Consequently, client-facing URLs generated by 9Router and locally run 9RTKSync diagnostics can target the article stack or an unused port instead of this gateway; migrate the host-facing defaults to 8081 while retaining http://9router:20128 for container-to-container traffic.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update host-facing URLs to use the new port

When this compose is used from the host, the gateway now listens on 8081, but NEXT_PUBLIC_BASE_URL immediately below and the repository's local ROUTER_URL defaults still point to 127.0.0.1:20128. Consequently, client-facing URLs generated by 9Router and locally run 9RTKSync diagnostics can target the article stack or an unused port instead of this gateway; migrate the host-facing defaults/docs with the port change (while retaining the service URL inside Compose).

Useful? React with 👍 / 👎.

environment:
- DATA_DIR=/app/data
- PORT=20128
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 10 additions & 4 deletions docs/wiki/Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
267 changes: 267 additions & 0 deletions docs/wiki/Remote-Access.md
Original file line number Diff line number Diff line change
@@ -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:

Comment on lines +89 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document LiteLLM authentication before exposing it

The page explicitly directs OmniRoute and LiteLLM users to this remote-access recipe, but the mandatory authentication steps above only name 9Router's REQUIRE_LOGIN and REQUIRE_API_KEY flags. LiteLLM does not use those flags and must instead be protected with its master key; because the page never mentions or configures that prerequisite, a LiteLLM user following the advertised recipe can publish an unauthenticated proxy backed by paid accounts. Add gateway-specific authentication steps before the shared Tailscale instructions.

Useful? React with 👍 / 👎.

```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"
Comment on lines +99 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve per-gateway port in the Tailscale recipe

For a host running the three gateways side by side as this change advertises, this recipe assigns the 9Router default host port 20128 rather than its newly allocated 8081; applying the same advertised recipe to OmniRoute or LiteLLM would assign the same tailnet address and port again, causing bind conflicts. Keep the published ports (8081/8082/8083) when replacing only the loopback address, and update the curl and MagicDNS examples accordingly.

Useful? React with 👍 / 👎.


Comment on lines +99 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the assigned host port in the Tailscale recipe

When multiple documented gateways run on the same host, this recipe assigns them all the shown Tailnet port 20128, despite the commit allocating 8081, 8082, and 8083 specifically so they coexist. Following the stated “same recipe” for 9Router, OmniRoute, and LiteLLM therefore creates port conflicts on the Tailnet address; only the bind address should change here, while each gateway keeps its allocated host port (for this stack, 100.101.102.103:8081:20128) and matching client URL.

Useful? React with 👍 / 👎.

# 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://<your-address>/v1/models | head -1 # expect 401
curl -si https://<your-address>/ | head -1 # expect 401 or a login page

# The synchronizer panel should not be exposed at all
curl -si https://<your-address>:9091/ | head -1 # expect connection refused
Comment on lines +147 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check the panel over its actual HTTP protocol

If the panel is accidentally exposed on port 9091, this HTTPS probe still fails because the synchronizer serves plain HTTP there, so it cannot distinguish an exposed panel from a refused connection; piping through head also hides curl's failing exit status. In the exact misconfiguration this safety check is meant to detect, a reader can therefore conclude the panel is private while http://<address>:9091/ remains reachable. Probe the panel with HTTP or perform an explicit TCP check.

Useful? React with 👍 / 👎.

```
Comment on lines +147 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check the panel over its actual HTTP protocol

If the panel is accidentally reachable on port 9091, this HTTPS request still fails because the embedded dashboard serves plain HTTP, so the check cannot distinguish an exposed panel from a closed port; piping into head also hides curl's failure status. A user can therefore receive the expected-looking failure while the credential dashboard remains publicly reachable over http://<address>:9091; probe the actual HTTP endpoint or perform an explicit TCP-port check.

Useful? React with 👍 / 👎.


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.
Comment on lines +151 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide a route before promising remote panel access

With the panel mapping kept on 127.0.0.1, a remote tailnet client cannot connect to <tailnet-ip>:9091, and 9Router's built-in Cloudflare tunnel forwards to the gateway rather than this separate synchronizer service. Thus neither setup documented above makes the panel reachable as this sentence promises. Document a separate SSH/local port forward, Tailscale Serve or tunnel route, or bind the panel specifically to the tailnet address.

Useful? React with 👍 / 👎.


---

# 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://<seu-endereco>/v1/models | head -1 # espera-se 401
curl -si https://<seu-endereco>/ | 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.
1 change: 1 addition & 0 deletions docs/wiki/_Sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading