From 6d8af0121137b7a76452777ee31f4e69d22e0f7d Mon Sep 17 00:00:00 2001
From: Eliel Sousa
Date: Sat, 12 Sep 2026 18:29:40 -0300
Subject: [PATCH] feat: entrar no painel deixa de ser um enigma, e as portas
deixam de colidir
Quem subia a stack nao conseguia entrar no painel. Nao era falta de senha de
fabrica -- era o caminho para definir a propria senha estar escondido.
**A causa.** No .env.example, DASHBOARD_PASSWORD estava COMENTADA. Quem copiava
o arquivo, preenchia e subia a stack nunca via a variavel, ficava sem senha e
caia no fluxo da credencial de recuperacao, que nao tinha como adivinhar onde
estava. O bloco de credenciais foi reescrito: as duas linhas agora existem,
descomentadas e vazias, com o passo a passo em cima e o comando exato para ler
a credencial de recuperacao caso a stack ja tenha subido sem senha.
**Nao existe senha de fabrica, e continua nao existindo.** Um valor fixo
publicado na imagem e uma credencial publica no instante em que a imagem e
publicada. O que faltava era clareza, nao um default.
Um bloco "como entrar no painel" abre o README e a Home da wiki: endereco,
usuario, onde a senha e definida e o que fazer se nao foi.
**Configuracao.** `make setup` cria o .env a partir do exemplo sem sobrescrever
um existente, e lista quais variaveis ficaram em branco. Mantivemos a
substituicao ${VAR} em vez de env_file: env_file despeja o arquivo inteiro em
cada container, e o sincronizador receberia a senha do Postgres, que ele nao
usa.
Novo teste: toda variavel exigida por um compose tem de existir no
.env.example. Ele pega exatamente a falha que originou isto -- verificado
removendo DASHBOARD_PASSWORD do exemplo numa copia.
**Portas.** As stacks de exemplo do 9RTKSync e do OminiRTkSync publicavam as
duas em 20128: subir as duas na mesma maquina, que e o caso de quem compara os
gateways, dava conflito. O OmniRoute passa a publicar em 20129 e o LiteLLM em
20130, na mesma faixa, com a tabela de portas no README e na wiki.
**Documentacao.** Egress-And-Multi-Session.md sai de docs/ e entra em
docs/wiki/, que e de onde a wiki e gerada -- estava fora dela, sem aparecer no
indice nem no sidebar. Entra tambem na Home e no _Sidebar.
**Painel.** O seletor de idioma ficava mais alto que os botoes vizinhos: ele
carrega so a bandeira, um elemento com altura propria, e sem texto ao lado para
definir a linha ele esticava o botao. A barra de acoes passa a fixar a altura
de todos os controles.
---
.env.example | 34 ++++++---
Makefile | 20 +++++-
README.md | 80 +++++++++++++++++++++
docs/{ => wiki}/Egress-And-Multi-Session.md | 0
docs/wiki/Home.md | 24 +++++++
docs/wiki/Installation.md | 35 +++++++++
docs/wiki/_Sidebar.md | 1 +
src/nine_rtksync/web/render.py | 12 +++-
tests/test_env_cobre_composes.py | 60 ++++++++++++++++
9 files changed, 253 insertions(+), 13 deletions(-)
rename docs/{ => wiki}/Egress-And-Multi-Session.md (100%)
create mode 100644 tests/test_env_cobre_composes.py
diff --git a/.env.example b/.env.example
index b0868c9..1ec29fa 100644
--- a/.env.example
+++ b/.env.example
@@ -75,20 +75,32 @@ WEB_HOST=0.0.0.0
# host (9091 for 9RTKSync, 9092 for OminiRTKSync).
WEB_PORT=9090
-# HTTP Basic Auth credentials for dashboard protection.
-# IMPORTANT: Update these credentials upon first login via web interface or via .env!
+# ------------------------------------------------------------------------------
+# COMO ENTRAR NO PAINEL / HOW TO SIGN IN
+# ------------------------------------------------------------------------------
+# O painel pede usuario e senha. Preencha as DUAS linhas abaixo antes de subir a
+# stack -- e so isso. Nao existe senha de fabrica: um valor fixo publicado na
+# imagem seria uma credencial publica no instante em que a imagem e publicada.
#
-# Headless mode: when DASHBOARD_USER and/or DASHBOARD_PASSWORD are set, they become
-# the source of truth and the .dashboard_auth.json file written by the screen is
-# ignored. Changing the password from the panel then answers 409 Conflict. Comment
-# the two lines below to hand control back to the dashboard.
+# The panel asks for a user and a password. Fill in BOTH lines below before
+# bringing the stack up. There is no factory password, on purpose.
DASHBOARD_USER=admin
-# DASHBOARD_PASSWORD= # vazio: usa a credencial de recuperacao do primeiro boot
+DASHBOARD_PASSWORD=
-# Break-glass recovery credential. Sign in with user 'admin' and this value as the
-# password to regain access if the dashboard password is forgotten. When left empty,
-# a random value is generated on first boot, stored in .dashboard_recovery (mode
-# 0600) and written once to the log file.
+# Deixou DASHBOARD_PASSWORD vazia? O container gera uma credencial de
+# recuperacao no primeiro boot. Leia e entre com ela como usuario 'admin':
+# docker exec router-sync cat /app/data/db/.dashboard_recovery
+# Depois defina a sua senha pela tela.
+#
+# Com DASHBOARD_PASSWORD preenchida, o ambiente vira a fonte da verdade e a
+# troca de senha pela tela e recusada com aviso -- mude aqui e recrie o
+# container. Deixe-a vazia se preferir administrar a senha pela tela.
+
+# Credencial de recuperacao (break-glass). Se ficar vazia, um valor aleatorio e
+# gerado no primeiro boot, salvo em .dashboard_recovery (modo 0600) e o log
+# registra o ARQUIVO, nunca o valor. Continua valendo depois de definir a senha:
+# uma credencial de socorro que caduca ao definir a senha e inutil justamente
+# quando e necessaria.
# DASHBOARD_RECOVERY_HASH=
# ------------------------------------------------------------------------------
diff --git a/Makefile b/Makefile
index b4e55ca..6583aa2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,22 @@
-.PHONY: test test-container venv run status docker-build docker-run clean
+.PHONY: setup test test-container venv run status docker-build docker-run clean
+
+# Cria o .env a partir do .env.example. Nunca sobrescreve um .env existente:
+# ele carrega os seus segredos, e um `make setup` distraido nao pode apaga-los.
+# O docker compose le esse .env sozinho, por estar ao lado do compose.
+setup:
+ @if [ -f .env ]; then \
+ echo ".env ja existe — preservado."; \
+ else \
+ cp .env.example .env; \
+ echo ".env criado a partir de .env.example."; \
+ fi
+ @echo ""
+ @echo "Preencha no .env antes de subir a stack:"
+ @grep -nE '^[A-Z_]+=$$' .env | sed 's/^/ linha /' || echo " (nada obrigatorio em branco)"
+ @echo ""
+ @echo "O painel usa DASHBOARD_USER e DASHBOARD_PASSWORD. Sem senha definida,"
+ @echo "o primeiro acesso usa a credencial de recuperacao gerada no boot."
+
VENV ?= .venv
PYTHON ?= $(shell which $(VENV)/bin/python3 2>/dev/null || which python3 2>/dev/null)
diff --git a/README.md b/README.md
index 555119c..a5c7c34 100644
--- a/README.md
+++ b/README.md
@@ -39,8 +39,88 @@ request; a push to `master` republishes the wiki automatically.
---
+---
+
+## 🔑 Signing in to the dashboard
+
+| | |
+| :--- | :--- |
+| **Address** | `http://localhost:9091` |
+| **User** | `admin` — or whatever you set in `DASHBOARD_USER` |
+| **Password** | the value of `DASHBOARD_PASSWORD` in your `.env` |
+
+There is **no factory password**, and that is deliberate: a fixed password shipped
+in an image is public the moment the image is. You choose it once, in one place:
+
+```bash
+cp .env.example .env
+# edit .env:
+DASHBOARD_USER=admin
+DASHBOARD_PASSWORD=
+```
+
+Then bring the stack up. That user and that password are what the panel accepts.
+
+### Did not set a password, and now cannot get in?
+
+On first boot with `DASHBOARD_PASSWORD` empty, the container generates a
+**recovery credential** and writes it inside the data directory. Read it:
+
+```bash
+docker exec router-sync cat /app/data/db/.dashboard_recovery
+```
+
+Sign in as `admin` with that value, then set a real password on the screen. The
+recovery credential keeps working afterwards — it is break-glass, and one that
+stopped working the moment you set a password would be useless exactly when you
+need it.
+
+> **Português:** o painel pede usuário e senha. O usuário é `admin` (ou o que
+> estiver em `DASHBOARD_USER`) e a senha é a que **você** definir em
+> `DASHBOARD_PASSWORD` no `.env` — não existe senha de fábrica, porque um valor
+> fixo publicado na imagem é uma credencial pública. Se subiu sem definir senha,
+> use o comando acima para ler a credencial de recuperação e entre com ela.
+
## Running with Docker
+### Configuração: `.env` a partir do exemplo
+
+A configuração inteira vem de variáveis de ambiente, lidas de um `.env` ao lado
+do `docker-compose.yml` — o Compose o encontra sozinho, sem nenhuma flag.
+
+```bash
+make setup # cria o .env a partir do .env.example, sem sobrescrever um existente
+```
+
+O alvo lista, ao final, exatamente quais variáveis ficaram em branco e precisam
+ser preenchidas. Preencha e suba a stack.
+
+O `.env` **nunca** é versionado, e o `.env.example` não carrega nenhum valor de
+segredo — um valor publicado num arquivo de exemplo é, por definição, uma
+credencial pública. Um teste garante que toda variável exigida por um compose
+existe no exemplo, para que `cp .env.example .env` nunca produza um `.env`
+incompleto.
+
+### Portas, e por que cada uma é diferente
+
+Os três sincronizadores escutam na **mesma porta dentro do container** (`9090`)
+e publicam em portas diferentes no host, para que os três possam rodar lado a
+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` |
+| 9RTKSync (painel) | `9090` | `9091` |
+| OminiRTkSync (painel) | `9090` | `9092` |
+| LiteLlmRTKSync (painel) | `9090` | `9093` |
+
+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.
+
+
Official multi-architecture Docker images (`linux/amd64` and `linux/arm64`) are published automatically to the GitHub Container Registry (GHCR):
```bash
diff --git a/docs/Egress-And-Multi-Session.md b/docs/wiki/Egress-And-Multi-Session.md
similarity index 100%
rename from docs/Egress-And-Multi-Session.md
rename to docs/wiki/Egress-And-Multi-Session.md
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 9308769..04f808b 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 |
+| [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 |
@@ -63,6 +64,29 @@ differ so they can run side by side: `9091` for 9RTKSync, `9092` for OminiRTKSyn
---
+---
+
+## Signing in to the dashboard
+
+| | |
+| :--- | :--- |
+| **Address** | `http://localhost:9091` |
+| **User** | `admin` — or whatever `DASHBOARD_USER` says |
+| **Password** | the value you set in `DASHBOARD_PASSWORD` |
+
+There is **no factory password**: a fixed one shipped in an image is public the
+moment the image is. Set yours in `.env` before bringing the stack up.
+
+Brought it up without setting one? The container generated a recovery
+credential on first boot — read it and sign in as `admin`, then set a real
+password on the screen:
+
+```bash
+docker exec router-sync cat /app/data/db/.dashboard_recovery
+```
+
+Full detail in [Authentication](Authentication).
+
## License
MIT — see [LICENSE](https://github.com/pathbit/9RTKSync/blob/master/LICENSE).
diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md
index dbdb38d..63e86ef 100644
--- a/docs/wiki/Installation.md
+++ b/docs/wiki/Installation.md
@@ -143,3 +143,38 @@ docker compose up -d 9rtksync
State that survives upgrades lives in the data volume: `.dashboard_auth.json` (screen-set
credentials), `.dashboard_recovery` (break-glass hash) and `ui_prefs.sqlite` (interface
language). None of them are stored in the gateway's own database.
+
+## Configuration: `.env` from the example
+
+Everything is configured by environment variable, read from a `.env` next to the
+compose file — Compose finds it on its own, with no flag.
+
+```bash
+make setup # creates .env from .env.example, never overwriting an existing one
+```
+
+The target then lists exactly which variables were left blank. Fill them in and
+bring the stack up.
+
+`.env` is never versioned, and `.env.example` carries no secret value — a value
+published in an example file is a public credential by definition. A test
+guarantees every variable a compose requires exists in the example, so
+`cp .env.example .env` never produces an incomplete `.env`.
+
+## Ports
+
+The three synchronizers listen on the **same port inside the container**
+(`9090`) and publish on different host ports, so all three can run side by side.
+Same for the gateways.
+
+| Service | Inside | Published |
+| :--- | :--- | :--- |
+| 9Router | `20128` | `20128` |
+| OmniRoute | `20128` | `20129` |
+| LiteLLM | `4000` | `20130` |
+| 9RTKSync panel | `9090` | `9091` |
+| OminiRTkSync panel | `9090` | `9092` |
+| LiteLlmRTKSync panel | `9090` | `9093` |
+
+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/_Sidebar.md b/docs/wiki/_Sidebar.md
index 303a895..4a928b8 100644
--- a/docs/wiki/_Sidebar.md
+++ b/docs/wiki/_Sidebar.md
@@ -7,6 +7,7 @@
- [Authentication](Authentication)
- [Logging](Logging)
- [Architecture](Architecture)
+- [Egress and Multi-Session](Egress-And-Multi-Session)
- [Troubleshooting](Troubleshooting)
- [Upstream Fixes](Upstream-Fixes)
diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py
index f724518..782c90f 100644
--- a/src/nine_rtksync/web/render.py
+++ b/src/nine_rtksync/web/render.py
@@ -664,6 +664,16 @@ def render_dashboard(
.accordion-button:not(.collapsed) {{ background: var(--surface-2); color: #fff; box-shadow: none; }}
.cron-log {{ white-space: pre-wrap; word-break: break-word; font-size: .8rem; color: #b9c0cf;
background: #0b0d12; border: 1px solid var(--line); border-radius: .35rem; padding: .6rem; }}
+ /* Barra de acoes do cabecalho: todos os controles com a MESMA altura.
+ O seletor de idioma carrega so a bandeira, um elemento com altura
+ propria; sem texto ao lado para definir a linha, ele esticava o botao e
+ ficava mais alto que os vizinhos. Fixar a altura em todos resolve na
+ origem, em vez de compensar caso a caso. */
+ .barra-acoes {{ display: flex; align-items: stretch; gap: .5rem; }}
+ .barra-acoes > * {{ display: flex; align-items: center; }}
+ .barra-acoes .btn {{ height: 2rem; padding-top: 0; padding-bottom: 0;
+ display: inline-flex; align-items: center; line-height: 1; }}
+ .barra-acoes .fi {{ line-height: 1; }}
@@ -682,7 +692,7 @@ def render_dashboard(
-
+
{render_language_switcher(lang)}