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
34 changes: 23 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

# ------------------------------------------------------------------------------
Expand Down
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<a senha que voce escolher>
```

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
Expand Down
24 changes: 24 additions & 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 |
| [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 Expand Up @@ -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).
Expand Down
35 changes: 35 additions & 0 deletions docs/wiki/Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/wiki/_Sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- [Authentication](Authentication)
- [Logging](Logging)
- [Architecture](Architecture)
- [Egress and Multi-Session](Egress-And-Multi-Session)
- [Troubleshooting](Troubleshooting)
- [Upstream Fixes](Upstream-Fixes)

Expand Down
12 changes: 11 additions & 1 deletion src/nine_rtksync/web/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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; }}
</style>
</head>
<body>
Expand All @@ -682,7 +692,7 @@ def render_dashboard(
</p>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<div class="barra-acoes">
{render_language_switcher(lang)}
<form method="post" action="/acoes/atualizar" class="m-0 d-inline">
<button class="btn btn-outline-light btn-sm" type="submit"
Expand Down
60 changes: 60 additions & 0 deletions tests/test_env_cobre_composes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Toda variável que um compose exige precisa existir no .env.example.

O fluxo documentado é `cp .env.example .env`, preencher e subir. Se o compose
usa uma variável que o exemplo não menciona, esse fluxo produz um `.env`
incompleto: ou a stack recusa subir com `variable is not set`, ou — pior — sobe
com um default silencioso que ninguém escolheu.

Foi exatamente o que aconteceu com o painel: o compose de teste não passava
`DASHBOARD_PASSWORD`, e não havia como definir a senha pelo `.env`.
"""

import os
import re
import unittest

RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# ${VAR}, ${VAR:-default}, ${VAR:?mensagem}
RX_USO = re.compile(r"\$\{([A-Z][A-Z0-9_]*)[:}?-]")
RX_DECLARA = re.compile(r"^\s*#?\s*([A-Z][A-Z0-9_]*)=", re.M)


def composes():
for nome in os.listdir(RAIZ):
if nome.startswith("docker-compose") and nome.endswith((".yml", ".yaml")):
yield os.path.join(RAIZ, nome)


def declaradas_no_exemplo():
caminho = os.path.join(RAIZ, ".env.example")
with open(caminho, encoding="utf-8") as f:
return set(RX_DECLARA.findall(f.read()))


class TestExemploCobreOsComposes(unittest.TestCase):
def test_every_variable_a_compose_needs_exists_in_the_example(self):
declaradas = declaradas_no_exemplo()
faltando = {}
for c in composes():
with open(c, encoding="utf-8") as f:
usadas = set(RX_USO.findall(f.read()))
ausentes = sorted(usadas - declaradas - {"HOME", "PWD", "USER"})
if ausentes:
faltando[os.path.basename(c)] = ausentes
self.assertEqual(
faltando,
{},
"variáveis exigidas por um compose e ausentes do .env.example: " + str(faltando),
)

def test_the_panel_credentials_are_offered_by_the_example(self):
# O caminho de entrada no painel tem de estar no exemplo, senão quem
# copia o arquivo não descobre que essas variáveis existem.
declaradas = declaradas_no_exemplo()
for v in ("DASHBOARD_USER", "DASHBOARD_PASSWORD"):
self.assertIn(v, declaradas, f"{v} precisa aparecer no .env.example")


if __name__ == "__main__":
unittest.main()
Loading