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
33 changes: 23 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,32 @@ WEB_HOST=0.0.0.0
# (9091 para o 9RTKSync, 9092 para o OminiRTKSync).
WEB_PORT=9090

# Credenciais de acesso HTTP Basic Auth do painel.
# IMPORTANTE: Altere estas credenciais no primeiro acesso via interface web ou 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.
#
# Modo headless: quando DASHBOARD_USER e/ou DASHBOARD_PASSWORD estao definidas, elas
# passam a ser a fonte de verdade e o arquivo .dashboard_auth.json gravado pela tela
# e ignorado. A troca de senha pelo painel passa a responder 409 Conflict. Basta
# comentar as duas linhas abaixo para devolver o controle ao 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=

# Credencial de recuperacao (break-glass). Entre com o usuario 'admin' e este valor
# como senha caso a senha do painel seja esquecida. Se ficar vazia, um valor aleatorio
# e gerado no primeiro boot, salvo em .dashboard_recovery (0600) e registrado no log.
# Deixou DASHBOARD_PASSWORD vazia? O container gera uma credencial de
# recuperacao no primeiro boot. Leia e entre com ela como usuario 'admin':
# docker exec ominirtksync cat /app/data/.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
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
.PHONY: venv test 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; \

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 Avoid copying the host-only gateway URL into Compose

When make setup is used with docker-compose.example.yml, this copies OMNIROUTE_URL=http://127.0.0.1:20128 from .env.example; Compose then substitutes it instead of the service-name default http://omniroute:20128. Inside the ominirtksync container, 127.0.0.1 refers to that container rather than OmniRoute, so the gateway probe fails and /healthz returns 503. The generated configuration needs a Compose-safe gateway URL, or the Compose service should not consume this host-only value.

Useful? React with 👍 / 👎.

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)

Expand Down
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,89 @@ request; a push to `master` republishes the wiki automatically.

---

---

## 🔑 Como entrar no painel

| | |
| :--- | :--- |
| **Endereço** | `http://localhost:9092` |
| **Usuário** | `admin` — ou o que você definir em `DASHBOARD_USER` |
| **Senha** | o valor de `DASHBOARD_PASSWORD` no seu `.env` |

**Não existe senha de fábrica**, e isso é deliberado: uma senha fixa publicada
na imagem vira credencial pública no instante em que a imagem é publicada. Você
escolhe a sua uma vez, num lugar só:

```bash
cp .env.example .env
# edite o .env:
DASHBOARD_USER=admin
DASHBOARD_PASSWORD=<a senha que voce escolher>
```

Suba a stack em seguida. Esse usuário e essa senha são o que o painel aceita.

### Subiu sem definir senha e agora não entra?

No primeiro boot com `DASHBOARD_PASSWORD` vazio, o container gera uma
**credencial de recuperação** e a grava dentro do diretório de dados. Leia com:

```bash
docker exec ominirtksync cat /app/data/.dashboard_recovery
```

Entre como `admin` com esse valor e defina a sua senha pela tela. A credencial
de recuperação continua valendo depois disso — ela é o arrombamento de vidro, e
uma que parasse de funcionar assim que você define uma senha seria inútil
justamente quando é necessária.

> **English:** the panel asks for user and password. The user is `admin` (or
> whatever `DASHBOARD_USER` says) and the password is the one **you** set in
> `DASHBOARD_PASSWORD` — there is no factory password, because a fixed value
> shipped in an image is a public credential. If you brought the stack up
> without setting one, use the command above to read the recovery credential.

## Como Executar via 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` |

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 the copyable Compose snippets to publish port 20129

The new table says OmniRoute is published on 20129 so it can coexist with 9Router, but the copyable Compose examples later in this README and in docs/wiki/Installation.md still bind OmniRoute to host port 20128. Users following those documented snippets therefore retain the collision this change is intended to fix; update the snippets and related public base URL to match the newly documented mapping.

Useful? React with 👍 / 👎.

| 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.


O pacote Docker oficial do OminiRTKSync é distribuído via GitHub Container Registry (GHCR):

```bash
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ services:
container_name: omniroute
restart: unless-stopped
ports:
- "127.0.0.1:20128:20128"
# Porta interna 20128 (padrao do OmniRoute); publicada em 20129 no host.
# O 9Router usa a 20128, e as duas stacks de exemplo colidiam quando
# alguem subia as duas na mesma maquina -- que e o caso de quem compara
# os dois gateways.
- "127.0.0.1:20129:20128"
environment:
- DATA_DIR=/app/data
- PORT=20128
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 OmniRoute 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:9092` |
| **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 ominirtksync cat /app/data/.dashboard_recovery
```

Full detail in [Authentication](Authentication).

## License

MIT — see [LICENSE](https://github.com/pathbit/OminiRTkSync/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 ominirtksync
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/omini_rtksync/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,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 @@ -680,7 +690,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