From 6d0ea14b8590b4c536a275ba9ba9600b9c0780bf Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 11:04:22 -0300 Subject: [PATCH 01/17] fix(sync): grava expires_at em ISO e testStatus 'active' no OmniRoute O sincronizador gravava expires_at como epoch numerico em texto (str(expires_at_ms)) numa coluna TEXT. O OmniRoute le esse campo com new Date(...) em src/lib/tokenHealthCheck.ts, e "1789999999000" e um Invalid Date -> NaN -> getEffectiveTokenExpiryMs devolve 0 -> hasKnownExpiry falso -> a renovacao preventiva do gateway nunca dispara para aquela conexao. Tambem gravava test_status = 'ok'. O OmniRoute so reconhece 'active' como saudavel (clearAccountError em src/sse/services/auth.ts e as checagens em tokenHealthCheck.ts), entao a conexao aparecia como estando em erro. Passa a gravar ISO-8601 UTC e 'active' - os mesmos formatos que o gateway usa nativamente. No schema JSON (9Router) expiresAt continua numerico, que e o formato que aquele gateway espera. O fix correspondente do lado do gateway foi enviado em diegosouzapw/OmniRoute#13444. --- src/omini_rtksync/database.py | 50 ++++++++++++++++------- tests/test_database.py | 77 ++++++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/omini_rtksync/database.py b/src/omini_rtksync/database.py index 1f9b459..7c85be0 100644 --- a/src/omini_rtksync/database.py +++ b/src/omini_rtksync/database.py @@ -1,4 +1,4 @@ -"""Safe access and relational mutation for OmniRoute SQLite database (storage.sqlite).""" +"""Acesso e mutação segura do banco SQLite do OmniRoute (storage.sqlite).""" import json import os @@ -10,14 +10,14 @@ def get_db_connection(db_path: str) -> sqlite3.Connection: if not os.path.exists(db_path): - raise FileNotFoundError(f"OmniRoute SQLite database not found at: {db_path}") + raise FileNotFoundError(f"Banco SQLite do OmniRoute não encontrado em: {db_path}") conn = sqlite3.connect(db_path, timeout=15.0) conn.row_factory = sqlite3.Row return conn def detect_connection_table(conn: sqlite3.Connection) -> str: - """Detect whether OmniRoute uses provider_connections or providerConnections table.""" + """Detecta se o OmniRoute utiliza a tabela provider_connections ou providerConnections.""" c = conn.cursor() c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('provider_connections', 'providerConnections')") row = c.fetchone() @@ -27,7 +27,7 @@ def detect_connection_table(conn: sqlite3.Connection) -> str: def get_all_connections(db_path: str) -> List[Dict[str, Any]]: - """Load all connections registered in OmniRoute.""" + """Carrega todas as conexões cadastradas no OmniRoute.""" conn = get_db_connection(db_path) try: tbl = detect_connection_table(conn) @@ -39,7 +39,7 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: keys = r.keys() item = dict(r) - # Normalization of relational column names + # Normalização de nomes de colunas relacionais provider = item.get("provider", "") name = item.get("name") or item.get("display_name") or provider access_token = item.get("access_token") or item.get("accessToken") @@ -48,7 +48,7 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: expires_at = item.get("expires_at") or item.get("expiresAt") test_status = item.get("test_status") or item.get("testStatus") or "ok" - # If JSON 'data' field is present (9Router style schema), merge fields + # Se houver campo JSON 'data' (formato 9Router), funde os campos if "data" in keys and isinstance(item["data"], str): try: d = json.loads(item["data"]) @@ -78,10 +78,19 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: conn.close() +def to_iso_utc(epoch_ms: int) -> str: + """Converte epoch em milissegundos para o ISO-8601 em UTC que o OmniRoute grava nativamente.""" + return ( + datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + def update_connection( db_path: str, connection_id: str, access_token: str, refresh_token: str, expires_at_ms: int ) -> bool: - """Update normalized credentials in the detected connection table.""" + """Atualiza as credenciais normalizadas na tabela detectada.""" conn = get_db_connection(db_path) tbl = detect_connection_table(conn) now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") @@ -91,17 +100,28 @@ def update_connection( cols = [c["name"] for c in cursor.fetchall()] if "access_token" in cols: - # OmniRoute relational schema (provider_connections) + # Tabela relacional do OmniRoute (provider_connections). + # + # expires_at é uma coluna TEXT e o OmniRoute a lê com `new Date(...)` + # (src/lib/tokenHealthCheck.ts). Um epoch numérico gravado como texto + # vira Invalid Date -> NaN -> o health check conclui que a conexão não + # tem expiração conhecida e nunca renova o token preventivamente. + # Por isso gravamos ISO-8601, o mesmo formato nativo do gateway. + # + # test_status precisa ser 'active': é o único valor que o OmniRoute + # trata como saudável (src/sse/services/auth.ts::clearAccountError e + # tokenHealthCheck.ts). 'ok' não é reconhecido e faz a conexão parecer + # estar em estado de erro. cursor.execute( f""" UPDATE {tbl} - SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'ok', updated_at = ? + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? WHERE id = ? """, - (access_token, refresh_token, str(expires_at_ms), now_iso, connection_id), + (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), ) elif "data" in cols: - # Compatible JSON format + # Formato compatível com JSON cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) row = cursor.fetchone() d = {} @@ -114,7 +134,10 @@ def update_connection( if refresh_token: d["refreshToken"] = refresh_token d["expiresAt"] = expires_at_ms - d["testStatus"] = "ok" + # 'active' é o valor que dispara o reset de estado de saúde no + # 9Router (resetHealthStateOnActivation em connectionsRepo.js); + # 'ok' só é reconhecido pela UI e não limpa travas de erro. + d["testStatus"] = "active" cursor.execute( f"UPDATE {tbl} SET data = ?, updatedAt = ? WHERE id = ?", (json.dumps(d), now_iso, connection_id), @@ -126,7 +149,7 @@ def update_connection( def get_all_combos(db_path: str) -> List[Dict[str, Any]]: - """Load combos registered in OmniRoute if table exists.""" + """Carrega combos cadastrados no OmniRoute se a tabela existir.""" conn = get_db_connection(db_path) try: cursor = conn.cursor() @@ -152,4 +175,3 @@ def get_all_combos(db_path: str) -> List[Dict[str, Any]]: return result finally: conn.close() - diff --git a/tests/test_database.py b/tests/test_database.py index a123ecf..61f532d 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,5 +1,6 @@ -"""Unit tests for OmniRoute SQLite database operations.""" +"""Testes unitários de manipulação do banco SQLite do OmniRoute.""" +import json import os import sqlite3 import tempfile @@ -77,7 +78,79 @@ def test_update_connection(self): conns = get_all_connections(self.db_path) ag = next(c for c in conns if c["id"] == "conn-ag-1") self.assertEqual(ag["accessToken"], "new-tok-789") - self.assertEqual(ag["expiresAt"], "1789999999000") + self.assertEqual( + ag["expiresAt"], + datetime.fromtimestamp(1789999999, tz=timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + ) + + def test_update_connection_writes_iso_expiry(self): + """expires_at deve sair em ISO-8601 UTC, o formato que o OmniRoute lê com new Date().""" + expires_at_ms = 1789999999000 + update_connection( + self.db_path, + "conn-ag-1", + access_token="tok", + refresh_token="ref", + expires_at_ms=expires_at_ms, + ) + + conn = sqlite3.connect(self.db_path) + row = conn.execute( + "SELECT expires_at, test_status FROM provider_connections WHERE id = 'conn-ag-1'" + ).fetchone() + conn.close() + stored_expiry, stored_status = row + + # Precisa ser texto ISO parseável, e não um epoch numérico em texto. + self.assertFalse( + stored_expiry.isdigit(), + "epoch numerico em texto vira Invalid Date no OmniRoute e desliga a renovacao preventiva", + ) + parsed = datetime.fromisoformat(stored_expiry.replace("Z", "+00:00")) + self.assertEqual(parsed.tzinfo, timezone.utc) + + # E o instante tem que sobreviver ao round-trip sem perda. + self.assertEqual(int(parsed.timestamp() * 1000), expires_at_ms) + + # 'active' e o unico test_status que o OmniRoute trata como saudavel. + self.assertEqual(stored_status, "active") + + def test_update_connection_json_schema_variant(self): + """No schema JSON (9Router), expiresAt continua numerico e testStatus vira 'active'.""" + json_db = os.path.join(self.temp_dir.name, "data.sqlite") + conn = sqlite3.connect(json_db) + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, data TEXT, updatedAt TEXT)" + ) + conn.execute( + "INSERT INTO providerConnections (id, data, updatedAt) VALUES (?, ?, ?)", + ("conn-json-1", json.dumps({"provider": "antigravity", "accessToken": "old"}), "x"), + ) + conn.commit() + conn.close() + + ok = update_connection( + json_db, + "conn-json-1", + access_token="new-tok", + refresh_token="new-ref", + expires_at_ms=1789999999000, + ) + self.assertTrue(ok) + + conn = sqlite3.connect(json_db) + raw = conn.execute( + "SELECT data FROM providerConnections WHERE id = 'conn-json-1'" + ).fetchone()[0] + conn.close() + stored = json.loads(raw) + + # O 9Router guarda expiresAt dentro de um blob JSON, entao o numero sobrevive. + self.assertEqual(stored["expiresAt"], 1789999999000) + self.assertEqual(stored["testStatus"], "active") + self.assertEqual(stored["accessToken"], "new-tok") if __name__ == "__main__": From 8d24161ccb1e72ef784522a1247cfbf833a38977 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 11:04:23 -0300 Subject: [PATCH 02/17] feat: dashboard server-side, i18n, log persistente e credencial de recuperacao Espelha no OminiRTKSync a mesma evolucao aplicada ao projeto irmao 9RTKSync. Servidor web - ThreadingHTTPServer no lugar do HTTPServer de uma thread so, e handle_error passa a engolir desconexao do cliente em vez de imprimir traceback. Era a origem do "BrokenPipeError: [Errno 32] Broken pipe" em serve_healthz: o probe do Docker desistia enquanto o /healthz fazia uma chamada HTTP de saida de ate 3s ao gateway, com o servidor bloqueado numa unica thread. - A sondagem ao gateway ganha cache de 30s. - Removido o Access-Control-Allow-Origin curinga; adicionados Cache-Control no-store, X-Frame-Options, X-Content-Type-Options e Referrer-Policy. Render server-side - O HTML e montado em render.py com os dados ja embutidos; o navegador nao consulta mais /api/status para desenhar a tela e o SQLite fica do lado do servidor. Acoes viram POST-Redirect-GET (/acoes/*). - Bootstrap 5 + Bootstrap Icons + flag-icons + jQuery no lugar dos emojis. - Idioma padrao ingles, com portugues e espanhol no seletor de bandeiras, persistido em SQLite proprio (prefs.py) - nunca no banco do gateway. - Cada conexao mostra por que foi ou nao renovada, e o cron guarda o log de cada ciclo com botao de detalhe; ciclo com falha aparece marcado. - models.py embrulha as linhas relacionais do OmniRoute na interface que o render consome, reconhecendo instancias locais (Ollama/vLLM/LM Studio) que antes eram rotuladas como provedor de nuvem por causa da chave de fachada. Log persistente - logs.py: arquivo rotativo diario, retencao configuravel por LOG_RETENTION_DAYS (padrao 30 dias) e expurgo dos vencidos no boot. LOG_DIR, LOG_LEVEL e LOG_TO_STDOUT completam o contrato. Autenticacao - auth.py: credenciais salvas mandam; sem nada salvo valem as de fabrica; e 'admin' com o hash de recuperacao entra sempre. Comparacoes em tempo constante. O hash vem de DASHBOARD_RECOVERY_HASH ou e gerado no primeiro boot, salvo com permissao 0600 e registrado uma vez no log. Configuracao e portas - DASHBOARD_USER/DASHBOARD_PASSWORD explicitas vencem o arquivo da tela. - Novas CRON_INTERVAL e CRON_ENABLED. - Porta interna padronizada em 9090 (igual no 9RTKSync); o host publica 9092. Container renomeado para ominirtksync, para nao colidir com o irmao. Testes: 21 -> 96 passando. --- .env.example | 76 +- .github/ISSUE_TEMPLATE/bug_report.yml | 27 +- .github/ISSUE_TEMPLATE/feature_request.yml | 15 +- .github/PULL_REQUEST_TEMPLATE.md | 25 +- Dockerfile | 6 +- Makefile | 27 +- README.md | 140 ++-- docker-compose.example.yml | 24 +- docker-compose.test.yml | 12 - run_tests.sh | 18 - src/omini_rtksync/__init__.py | 3 +- src/omini_rtksync/auth.py | 126 +++ src/omini_rtksync/cli.py | 115 +-- src/omini_rtksync/config.py | 84 +- src/omini_rtksync/cron.py | 43 +- src/omini_rtksync/discovery.py | 27 +- src/omini_rtksync/i18n.py | 281 +++++++ src/omini_rtksync/logs.py | 136 ++++ src/omini_rtksync/models.py | 120 +++ src/omini_rtksync/normalizer.py | 5 +- src/omini_rtksync/prefs.py | 77 ++ src/omini_rtksync/providers.py | 39 +- src/omini_rtksync/render.py | 619 ++++++++++++++ src/omini_rtksync/web.py | 897 +++++++-------------- tests/__init__.py | 2 +- tests/test_auth_recovery.py | 189 +++++ tests/test_cli.py | 4 +- tests/test_config_env.py | 103 ++- tests/test_cron.py | 2 +- tests/test_discovery.py | 2 +- tests/test_gateway_diag.py | 2 +- tests/test_logs.py | 140 ++++ tests/test_normalizer.py | 2 +- tests/test_web_auth.py | 4 +- tests/test_web_render.py | 303 +++++++ tests/test_web_resilience.py | 216 +++++ 36 files changed, 2988 insertions(+), 923 deletions(-) delete mode 100644 docker-compose.test.yml delete mode 100755 run_tests.sh create mode 100644 src/omini_rtksync/auth.py create mode 100644 src/omini_rtksync/i18n.py create mode 100644 src/omini_rtksync/logs.py create mode 100644 src/omini_rtksync/models.py create mode 100644 src/omini_rtksync/prefs.py create mode 100644 src/omini_rtksync/render.py create mode 100644 tests/test_auth_recovery.py create mode 100644 tests/test_logs.py create mode 100644 tests/test_web_render.py create mode 100644 tests/test_web_resilience.py diff --git a/.env.example b/.env.example index a992a77..d7911c4 100644 --- a/.env.example +++ b/.env.example @@ -1,64 +1,90 @@ # ============================================================================== # OminiRoute Universal Token & Connection Synchronizer (OminiRTKSync) -# Environment Variable Configuration Template +# Modelo de Configuracao de Variaveis de Ambiente # ============================================================================== -# To configure your local or container environment -# 1. Copy this file to .env (cp .env.example .env) -# 2. Adjust values for your infrastructure and credentials -# 3. The .env file is strictly ignored by version control (.gitignore) +# Para configurar seu ambiente local ou container: +# 1. Copie este arquivo para .env: cp .env.example .env +# 2. Ajuste os valores conforme sua infraestrutura e credenciais +# 3. O arquivo .env e estritamente ignorado pelo controle de versao (.gitignore) # ============================================================================== # ------------------------------------------------------------------------------ -# 1. Storage and Token Discovery +# 1. Armazenamento e Descoberta de Tokens # ------------------------------------------------------------------------------ -# Host base directory mounted inside container (e.g. /root/host when mounted ${HOME}:/root/host:ro) +# Diretorio base do host montado no container (ex: /root/host quando montado $HOME:/root/host:ro) HOST_HOME=/root/host -# Absolute path to OmniRoute SQLite storage file -# Default inside OmniRoute container: /app/data/storage.sqlite +# Caminho absoluto para o banco de dados SQLite do OmniRoute +# Padrao no container OmniRoute: /app/data/storage.sqlite DB_PATH=/app/data/storage.sqlite -# Optional custom path to direct Antigravity OAuth credentials +# Caminho opcional para credenciais OAuth diretas do Antigravity # ANTIGRAVITY_TOKEN_PATH=/root/host/.gemini/oauth_creds.json # ------------------------------------------------------------------------------ -# 2. OmniRoute Gateway Connectivity +# 2. Conectividade com o Gateway OmniRoute # ------------------------------------------------------------------------------ -# OmniRoute gateway base URL for connectivity tests and diagnostics +# URL base do gateway OmniRoute para testes de conexao e diagnosticos OMNIROUTE_URL=http://127.0.0.1:20128 # ------------------------------------------------------------------------------ -# 3. Synchronization Parameters and Cron Scheduler +# 3. Parametros de Sincronizacao e Agendador Cron # ------------------------------------------------------------------------------ -# Interval in seconds between synchronization passes and cron renewals (default 300s / 5min) +# Intervalo em segundos entre as execucoes do sincronizador e do cron de renovacao (padrao: 300s / 5min) SYNC_INTERVAL=300 -# Margin in seconds before expiration to trigger proactive renewal (default 900s / 15min) +# Margem em segundos antes da expiracao para renovar tokens proativamente (padrao: 900s / 15min) REFRESH_MARGIN=900 +# Intervalo exclusivo do agendador cron. Se omitido, herda o valor de SYNC_INTERVAL. +# CRON_INTERVAL=300 + +# Liga/desliga o agendador automatico (1=ligado, 0=desligado). +# Com 0, a sincronizacao so acontece por disparo manual (--once ou POST /api/sync). +CRON_ENABLED=1 + # ------------------------------------------------------------------------------ -# 4. Web Server and Administrative Dashboard +# 4. Servidor Web e Dashboard Administrativo # ------------------------------------------------------------------------------ -# Enable administrative web dashboard (1=enabled, 0=disabled) +# Ativa o dashboard web administrativo (1=ativado, 0=desativado) ENABLE_WEB_DASHBOARD=1 -# Network binding interface for internal web server +# Host de escuta do servidor web interno WEB_HOST=0.0.0.0 -# HTTP port for the OminiRTKSync web dashboard (default 9191) -WEB_PORT=9191 +# Porta INTERNA do servidor web dentro do container (padrao: 9090). +# Ela e igual nos dois sincronizadores; o que muda e a porta publicada no host +# (9091 para o 9RTKSync, 9092 para o OminiRTKSync). +WEB_PORT=9090 -# HTTP Basic Auth credentials for dashboard access -# IMPORTANT Update these credentials upon first login via web interface or via .env +# Credenciais de acesso HTTP Basic Auth do painel. +# IMPORTANTE: Altere estas credenciais no primeiro acesso via interface web ou via .env! +# +# 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. DASHBOARD_USER=admin DASHBOARD_PASSWORD=pathbit +# 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. +# DASHBOARD_RECOVERY_HASH= + # ------------------------------------------------------------------------------ -# 5. OmniRoute Stack Integration (Docker Compose) +# Log persistente em arquivo # ------------------------------------------------------------------------------ -# Initial password and JWT secret for OmniRoute +LOG_DIR=/app/data/logs +LOG_RETENTION_DAYS=30 +LOG_LEVEL=INFO +LOG_TO_STDOUT=1 + +# ------------------------------------------------------------------------------ +# 5. Integracao com a Stack OmniRoute (Docker Compose) +# ------------------------------------------------------------------------------ +# Senha inicial e segredo JWT do OmniRoute INITIAL_PASSWORD=PathbitDevs2026! JWT_SECRET=omniroute-jwt-secret-key-pathbit REQUIRE_API_KEY=false REQUIRE_LOGIN=false - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 911f590..37ab560 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,39 +1,38 @@ -name: Bug Report -description: Report a defect or synchronization issue in OminiRTKSync +name: Relato de Bug +description: Reporte um problema ou falha de sincronização no OminiRTKSync title: "[BUG] " labels: ["bug"] body: - type: markdown attributes: value: | - Thank you for helping improve OminiRTKSync! Please provide details about the issue so we can reproduce and fix it promptly. + Obrigado por ajudar a aprimorar o OminiRTKSync! Forneça detalhes sobre a falha para podermos reproduzir e corrigir rapidamente. - type: input id: provider attributes: - label: Affected Provider - description: Which OmniRoute connection failed? (e.g., Antigravity, Claude, Groq, Mistral, Ollama) - placeholder: e.g., Antigravity / Google OAuth + label: Provedor Afetado + description: Qual conexão do OmniRoute apresentou falha? (ex: Antigravity, Claude, Groq, Mistral, Ollama) + placeholder: ex: Antigravity / Google OAuth validations: required: true - type: textarea id: description attributes: - label: Problem Description - description: What happened and what was the expected behavior? + label: Descrição do Problema + description: O que aconteceu e qual era o comportamento esperado? validations: required: true - type: textarea id: logs attributes: - label: Logs and Error Messages - description: Paste relevant container logs or command output + label: Logs e Mensagens de Erro + description: Cole os logs do container ou do comando OminiRTKSync render: shell - type: input id: version attributes: - label: OminiRTKSync Version and Environment - description: Docker version, container image tag, or local Python version - placeholder: e.g., ghcr.io/pathbit/ominirtksync:latest on macOS / Linux + label: Versão do OminiRTKSync e Ambiente + description: Versão do Docker, imagem utilizada ou Python local + placeholder: ex: ghcr.io/pathbit/ominirtksync:latest no macOS Sequoia validations: required: true - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index f6fb3c4..f4d98b2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,22 +1,21 @@ -name: Feature Request / Provider Suggestion -description: Suggest a new feature, connection provider, or enhancement for OminiRTKSync +name: Sugestão de Melhoria / Provedor +description: Sugira uma nova funcionalidade, provedor ou melhoria para o OminiRTKSync title: "[FEAT] " labels: ["enhancement"] body: - type: markdown attributes: value: | - Thank you for proposing improvements for OminiRTKSync! + Obrigado por propor melhorias para o OminiRTKSync! - type: textarea id: idea attributes: - label: Suggestion Description - description: Explain in detail your use case or requested enhancement + label: Descrição da Sugestão + description: Explique detalhadamente o caso de uso ou a melhoria desejada validations: required: true - type: textarea id: context attributes: - label: OmniRoute Context - description: How does OmniRoute manage this connection and how should OminiRTKSync interact with it? - + label: Contexto no OmniRoute + description: Como o OmniRoute gerencia essa conexão e de que forma o OminiRTKSync deve interagir? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 04c3425..2ed3f41 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,18 +1,17 @@ -## Description of Changes +## Descrição das Alterações -Explain clearly what this Pull Request resolves, enhances, or adds to `OminiRTKSync`. +Explique de forma clara e objetiva o que este Pull Request resolve, aprimora ou adiciona ao `OminiRTKSync`. -## Change Type +## Tipo de Alteração -- [ ] Bug fix -- [ ] New feature or provider support -- [ ] Refactoring or performance optimization -- [ ] Documentation update -- [ ] Testing or CI pipeline improvements +- [ ] Correção de bug (bug fix) +- [ ] Nova funcionalidade ou suporte a novo provedor +- [ ] Refatoração de código sem impacto em comportamento +- [ ] Atualização de documentação +- [ ] Melhorias em testes ou pipeline de CI -## Verification Checklist - -- [ ] Code executed and validated in local virtual environment (`source .venv/bin/activate`). -- [ ] Unit test suite passing (`python3 -m unittest discover -s tests` or `./run_tests.sh`). -- [ ] No conflicts with the `master` branch. +## Checklist de Validação +- [ ] Código executado e validado em virtual environment local (`source .venv/bin/activate`). +- [ ] Suíte de testes unitários passando (`python3 -m unittest discover -s tests`). +- [ ] Sem conflitos com a branch `master`. diff --git a/Dockerfile b/Dockerfile index 672fddc..ebe925d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENV DB_PATH=/app/data/storage.sqlite ENV OMNIROUTE_URL=http://127.0.0.1:20128 ENV SYNC_INTERVAL=300 ENV REFRESH_MARGIN=900 -ENV WEB_PORT=9191 +ENV WEB_PORT=9090 ENV WEB_HOST=0.0.0.0 ENV ENABLE_WEB_DASHBOARD=1 @@ -34,10 +34,10 @@ COPY pyproject.toml /app/ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -e . -EXPOSE 9191 +EXPOSE 9090 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ - CMD /opt/venv/bin/python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9191/healthz', timeout=3)" || exit 1 + CMD /opt/venv/bin/python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)" || exit 1 ENTRYPOINT ["/opt/venv/bin/python3", "-m", "omini_rtksync.cli"] CMD ["--daemon"] diff --git a/Makefile b/Makefile index 1c223f4..a8b51b0 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,16 @@ -.PHONY: test test-container venv run status docker-build docker-run clean +.PHONY: venv test run status docker-build docker-run clean VENV ?= .venv -PYTHON ?= $(shell which $(VENV)/bin/python3 2>/dev/null || which python3 2>/dev/null) - -# Run tests: uses local virtualenv if present; otherwise runs inside Docker container -test: - @if [ -x "$(VENV)/bin/python3" ]; then \ - echo "Running tests in local virtual environment ($(VENV))..."; \ - PYTHONPATH=src $(VENV)/bin/python3 -m unittest discover -s tests -p "test_*.py"; \ - elif command -v python3 >/dev/null 2>&1; then \ - echo "Running tests with host python3..."; \ - PYTHONPATH=src python3 -m unittest discover -s tests -p "test_*.py"; \ - else \ - echo "Local Python not detected. Running tests directly in Docker container..."; \ - $(MAKE) test-container; \ - fi - -# Run tests inside Docker container (zero dependencies on host other than Docker) -test-container: - docker run --rm -v "$$(pwd)":/app -w /app -e PYTHONPATH=/app/src python:3.14-alpine python3 -m unittest discover -s tests -p "test_*.py" - +PYTHON ?= $(shell which $(VENV)/bin/python3 2>/dev/null || which python3) venv: python3 -m venv $(VENV) $(VENV)/bin/pip install --upgrade pip $(VENV)/bin/pip install -e . +test: + PYTHONPATH=src $(PYTHON) -m unittest discover -s tests -p "test_*.py" + run: PYTHONPATH=src $(PYTHON) -m omini_rtksync.cli --daemon @@ -36,7 +21,7 @@ docker-build: docker build -t ominirtksync:latest -t ghcr.io/pathbit/ominirtksync:latest . docker-run: - docker run --rm -it --name router-sync -p 9191:9191 ominirtksync:latest + docker run --rm -it --name ominirtksync -p 9092:9090 ominirtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/README.md b/README.md index 956c40b..eafdfb5 100644 --- a/README.md +++ b/README.md @@ -6,38 +6,38 @@ [![Python Version](https://img.shields.io/badge/python-3.14.7-blue.svg)](https://www.python.org/ftp/python/3.14.7/python-3.14.7-macos11.pkg) [![Docker Package](https://img.shields.io/badge/docker-ghcr.io%2Fpathbit%2Fominirtksync-blue)](https://github.com/pathbit/OminiRTkSync/pkgs/container/ominirtksync) -**`OminiRTKSync`** (*OminiRoute Universal Token & Connection Synchronizer*) is the dedicated connection guardian and token synchronizer for the [OmniRoute](https://github.com/diegosouzapw/OmniRoute) AI gateway. It manages relational credential persistence, continuous OAuth token renewal, and disruption-free routing across AI providers. +O **`OminiRTKSync`** (*OminiRoute Universal Token & Connection Synchronizer*) é o sincronizador e guardião de conexões dedicado ao gateway [OmniRoute](https://github.com/diegosouzapw/OmniRoute). Ele gerencia a persistência relacional de credenciais, auto-renovação de tokens OAuth e prevenção de interrupções de rota em inteligência artificial. -If you are running the original 9Router stack, refer to the sibling project [9RTKSync](https://github.com/pathbit/9RTKSync) engineered for [9Router](https://github.com/decolua/9router). +Caso esteja utilizando o 9Router original, utilize o projeto irmão [9RTKSync](https://github.com/pathbit/9RTKSync) configurado para a arquitetura do [9Router](https://github.com/decolua/9router). --- -## Key Features +## Recursos Principais -* **Relational Schema Support for OmniRoute** - * Direct synchronization with SQLite's `provider_connections` table (`storage.sqlite`), managing native relational fields including `access_token`, `refresh_token`, `expires_at`, and `test_status`. -* **Continuous OAuth Token Renewal** - * Automatic renewal of Google Antigravity and Gemini CLI accounts prior to expiration using a configurable safety buffer. -* **Database Auto-Discovery** - * Automatic path detection between standard container locations (`/app/data/storage.sqlite`) and local developer setups (`~/.omniroute/data/storage.sqlite`). -* **Embedded Web Dashboard** - * Embedded control panel on port `9191` for monitoring the status of registered connections and triggering on-demand synchronization passes. -* **Complete Virtual Environment Isolation** - * Secure and isolated execution inside Python virtual environments both within Docker containers (`/opt/venv`) and in local development environments (`.venv`). +* **Compatibilidade com Schema Relacional do OmniRoute** + * Sincronização direta com a tabela `provider_connections` do SQLite (`storage.sqlite`), manipulando campos nativos como `access_token`, `refresh_token`, `expires_at` e `test_status`. +* **Renovação Contínua de Tokens OAuth** + * Auto-renovação de contas Google Antigravity e Gemini CLI antes de sua expiração com margem de segurança ajustável. +* **Auto-Detecção de Bancos de Dados** + * Detecção automática entre caminhos padrão do container (`/app/data/storage.sqlite`) e instalações locais (`~/.omniroute/data/storage.sqlite`). +* **Dashboard Web Embutido** + * Painel de controle na porta `9191` para monitoramento do estado de cada conexão registrada e acionamento sob demanda de sincronização. +* **Isolamento Completo em Virtual Environment** + * Execução segura e isolada em ambiente virtual Python tanto em containers Docker (`/opt/venv`) quanto em instalações de desenvolvimento local (`.venv`). --- -## How to Run via Docker +## Como Executar via Docker -The official multi-arch Docker package for OminiRTKSync is published to the GitHub Container Registry (GHCR): +O pacote Docker oficial do OminiRTKSync é distribuído via GitHub Container Registry (GHCR): ```bash docker pull ghcr.io/pathbit/ominirtksync:latest ``` -### Docker Compose Example +### Exemplo no Docker Compose -Integrate `OminiRTKSync` into your `docker-compose.yml` alongside [OmniRoute](https://github.com/diegosouzapw/OmniRoute): +Integre o `OminiRTKSync` ao seu `docker-compose.yml` junto ao [OmniRoute](https://github.com/diegosouzapw/OmniRoute): ```yaml services: @@ -94,19 +94,19 @@ volumes: --- -## How to Run Locally in Virtual Environment +## Como Executar Localmente em Virtual Environment -To run directly on your host machine using [Python 3.14.7](https://www.python.org/ftp/python/3.14.7/python-3.14.7-macos11.pkg): +Para executar diretamente no host utilizando [Python 3.14.7](https://www.python.org/ftp/python/3.14.7/python-3.14.7-macos11.pkg): -### 1. Clone the Repository +### 1. Clonar o Repositório ```bash git clone https://github.com/pathbit/OminiRTkSync.git cd OminiRTkSync ``` -### 2. Create and Activate Virtual Environment - +### 2. Criar e Ativar o Virtual Environment + ```bash python3 -m venv .venv source .venv/bin/activate @@ -114,106 +114,84 @@ pip install --upgrade pip pip install -e . ``` -### 3. Configure Environment Variables (.env) +### 3. Configurar Variáveis de Ambiente (.env) -Copy the official template to create your local `.env` file (the `.env` file is strictly ignored by git): +Copie o modelo oficial para criar seu `.env` local (o arquivo `.env` é estritamente ignorado no git): ```bash cp .env.example .env ``` -### 4. Available Commands +### 4. Comandos Disponíveis ```bash -# Display OmniRoute connection status table -OminiRTKSync --status --db-path /path/to/storage.sqlite +# Exibir status das conexões do OmniRoute +OminiRTKSync --status --db-path /caminho/para/storage.sqlite -# Execute an immediate single synchronization run -OminiRTKSync --once --db-path /path/to/storage.sqlite +# Executar uma rodada única imediata de sincronização +OminiRTKSync --once --db-path /caminho/para/storage.sqlite -# Run in continuous daemon mode with web dashboard -OminiRTKSync --daemon --db-path /path/to/storage.sqlite +# Executar em modo daemon contínuo com dashboard web +OminiRTKSync --daemon --db-path /caminho/para/storage.sqlite ``` --- -## Environment Variables +## Variáveis de Ambiente -| Variable | Default | Description | +| Variável | Padrão | Descrição | | :--- | :--- | :--- | -| `DB_PATH` | `/app/data/storage.sqlite` | Path to OmniRoute SQLite storage file | -| `OMNIROUTE_URL` | `http://127.0.0.1:20128` | Base URL for OmniRoute gateway health and connectivity checks | -| `SYNC_INTERVAL` | `300` | Interval in seconds between daemon passes and cron renewals | -| `REFRESH_MARGIN` | `900` | Safety buffer in seconds before expiration to trigger token refresh | -| `ENABLE_WEB_DASHBOARD` | `1` | Enable embedded HTTP web dashboard (`1` for yes, `0` for no) | -| `WEB_PORT` | `9191` | HTTP port for web dashboard | -| `WEB_HOST` | `0.0.0.0` | Network binding interface for web dashboard | -| `DASHBOARD_USER` | `admin` | Username for HTTP Basic Auth | -| `DASHBOARD_PASSWORD` | `pathbit` | Initial password for HTTP Basic Auth | -| `ANTIGRAVITY_TOKEN_PATH` | auto | Custom path to Antigravity token file | +| `DB_PATH` | `/app/data/storage.sqlite` | Caminho do arquivo SQLite do OmniRoute | +| `OMNIROUTE_URL` | `http://127.0.0.1:20128` | URL base do gateway OmniRoute para testes de conectividade | +| `SYNC_INTERVAL` | `300` | Intervalo em segundos entre varreduras no modo daemon e cron | +| `REFRESH_MARGIN` | `900` | Margem prévia em segundos para renovação de tokens | +| `ENABLE_WEB_DASHBOARD` | `1` | Ativa o dashboard web embutido (`1` para sim, `0` para não) | +| `WEB_PORT` | `9191` | Porta do dashboard web HTTP | +| `WEB_HOST` | `0.0.0.0` | Interface de rede para o servidor web | +| `DASHBOARD_USER` | `admin` | Usuário de autenticação HTTP Basic Auth | +| `DASHBOARD_PASSWORD` | `pathbit` | Senha padrão inicial de autenticação HTTP Basic Auth | +| `ANTIGRAVITY_TOKEN_PATH` | auto | Caminho customizado para arquivo de token do Antigravity | --- -## Web Dashboard +## Dashboard Web -With `ENABLE_WEB_DASHBOARD=1`, open in your browser: +Com `ENABLE_WEB_DASHBOARD=1`, acesse no navegador: 👉 **http://localhost:9191** -Dashboard capabilities: -* Monitoring of all connections registered in OmniRoute. -* Real-time activation status of API keys and OAuth 2.0 accounts. -* Triggering immediate synchronization via REST API (`POST /api/sync`). +Recursos do painel: +* Monitoramento de todas as conexões cadastradas no OmniRoute. +* Estado de ativação de chaves de API e contas OAuth 2.0. +* Disparo de sincronização imediata via API REST (`POST /api/sync`). --- -## Unit Testing - -You can run the full test suite with zero dependencies installed on your host machine (using Docker), or optionally inside a local Python virtual environment. - -### Option 1. Via Docker Container (Zero Host Installation) +## Testes Unitários -The only requirement is having Docker running: - -```bash -# Via shell script directly -./run_tests.sh - -# Or via Makefile -make test-container - -# Or via Docker Compose -docker compose -f docker-compose.test.yml run --rm test -``` - -### Option 2. Local Virtual Environment (Optional Prerequisites) - -If you prefer testing directly on your host machine with Python 3.14+: +Execute a suíte de testes completa dentro do virtual environment: ```bash source .venv/bin/activate -make test -# Or directly PYTHONPATH=src python3 -m unittest discover -s tests -p "test_*.py" ``` --- -## Contributing and Branch Protection +## Contribuição e Proteção da Branch Master -* The `master` branch is protected. All contributions must be submitted via Pull Requests and pass the complete CI matrix. -* Feedback and bug reports can be submitted via [Issues](https://github.com/pathbit/OminiRTkSync/issues). -* Official upstream gateway repository: [OmniRoute on GitHub](https://github.com/diegosouzapw/OmniRoute). +* A branch `master` é protegida. Toda contribuição deve ser enviada via Pull Request e passar pela suíte de integração contínua. +* Questões e sugestões podem ser submetidas em [Issues](https://github.com/pathbit/OminiRTkSync/issues). +* Referência oficial do projeto base: [OmniRoute no GitHub](https://github.com/diegosouzapw/OmniRoute). --- -## License +## 📄 Licença -Distributed under the MIT License. The full text is available in [LICENSE](https://github.com/pathbit/OminiRTkSync/blob/master/LICENSE). +Distribuído sob a Licença MIT. O texto completo está em [LICENSE](https://github.com/pathbit/OminiRTkSync/blob/master/LICENSE). -In short: you are free to use, copy, modify, merge, publish, distribute, sublicense, and sell copies, provided that copyright and permission notices are included in all copies. The software is provided as-is, without warranties. +Na prática: use, copie, altere e redistribua à vontade, inclusive comercialmente, desde que o aviso de copyright e a licença acompanhem as cópias. O software é fornecido como está, sem garantias. --- -Developed with ❤️ by [Pathbit](https://pathbit.co/) - +Desenvolvido com ❤️ pela [Pathbit](https://pathbit.co/) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index c0d0662..c35ed0c 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -22,13 +22,16 @@ services: ominirtksync: image: ghcr.io/pathbit/ominirtksync:latest - container_name: router-sync + container_name: ominirtksync restart: unless-stopped ports: - - "127.0.0.1:9191:9191" + # Porta interna 9090 (igual no 9RTKSync); publicada em 9092 no host. + # O bind em 127.0.0.1 mantem o painel e o SQLite fora da internet. + - "127.0.0.1:9092:9090" volumes: - omniroute_data:/app/data - ${HOME}:/root/host:ro + - ominirtksync_logs:/app/data/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite @@ -36,11 +39,26 @@ services: - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - - WEB_PORT=${WEB_PORT:-9191} + - WEB_PORT=${WEB_PORT:-9090} - DASHBOARD_USER=${DASHBOARD_USER:-admin} - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-pathbit} + # Credencial de emergencia (usuario 'admin' + este valor como senha). + # Se omitida, e gerada no primeiro boot e registrada no arquivo de log. + # - DASHBOARD_RECOVERY_HASH= + - CRON_ENABLED=${CRON_ENABLED:-1} + # - CRON_INTERVAL=300 # herda SYNC_INTERVAL quando omitido + - LOG_DIR=${LOG_DIR:-/app/data/logs} + - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} + - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - omniroute + healthcheck: + test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s volumes: omniroute_data: + ominirtksync_logs: diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index db5ca61..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: ominirtksync-tests - -services: - test: - image: python:3.14-alpine - container_name: ominirtksync-test-runner - volumes: - - .:/app - working_dir: /app - environment: - - PYTHONPATH=/app/src - command: ["python3", "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"] diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index 5edfbd4..0000000 --- a/run_tests.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -set -e - -echo "======================================================================" -echo "🧪 Running unit tests in Docker container (zero host dependencies)" -echo "======================================================================" - -if ! command -v docker >/dev/null 2>&1; then - echo "❌ Error: Docker not found. The only requirement is having Docker installed." >&2 - exit 1 -fi - -docker run --rm -v "$(pwd)":/app -w /app -e PYTHONPATH=/app/src python:3.14-alpine python3 -m unittest discover -s tests -p "test_*.py" - -echo "======================================================================" -echo "✅ All tests passed successfully inside the container!" -echo "======================================================================" - diff --git a/src/omini_rtksync/__init__.py b/src/omini_rtksync/__init__.py index 11b730e..5fb26a4 100644 --- a/src/omini_rtksync/__init__.py +++ b/src/omini_rtksync/__init__.py @@ -1,4 +1,4 @@ -"""OminiRTKSync · OminiRoute Universal Token & Connection Synchronizer. +"""OminiRTKSync: OmniRoute Universal Token & Connection Sync. Specialized token keeper, health validator and auto-healer for OmniRoute AI Gateways (https://github.com/diegosouzapw/OmniRoute). @@ -9,4 +9,3 @@ __email__ = "eliel@pathbit.co" __all__ = ["__version__", "__author__", "__email__"] - diff --git a/src/omini_rtksync/auth.py b/src/omini_rtksync/auth.py new file mode 100644 index 0000000..fa0e8d3 --- /dev/null +++ b/src/omini_rtksync/auth.py @@ -0,0 +1,126 @@ +"""Autenticação do dashboard com credencial de recuperação (break-glass). + +Regra de validação, nesta ordem: + +1. Se existem credenciais salvas (trocadas pela tela), elas são a fonte de verdade: + usuário e senha precisam bater exatamente com o que está salvo. +2. Se nada foi salvo ainda, valem as credenciais de fábrica (padrão ou vindas do + ambiente) — é o estado de primeiro acesso do container. +3. Independente do que existe salvo, o usuário `admin` com a senha igual ao + *hash de recuperação* sempre entra. É a saída de emergência para quem esqueceu + a senha, sem precisar apagar o volume do container. +4. Qualquer outra combinação é inválida. + +O hash de recuperação vem de DASHBOARD_RECOVERY_HASH. Quando a variável não é +definida, um hash aleatório é gerado no primeiro boot, gravado em disco com +permissão 0600 e registrado uma única vez no log — é lá que o operador vai buscá-lo. + +Todas as comparações usam hmac.compare_digest para não vazar informação por tempo +de resposta. +""" + +import hashlib +import hmac +import json +import os +import secrets +from typing import Optional, Tuple + +RECOVERY_FILE_NAME = ".dashboard_recovery" +RECOVERY_USER = "admin" + + +def constant_time_equals(a: str, b: str) -> bool: + """Compara duas strings em tempo constante.""" + return hmac.compare_digest(str(a or "").encode("utf-8"), str(b or "").encode("utf-8")) + + +def derive_recovery_hash(secret: str) -> str: + """Deriva o hash de recuperação exibido ao operador a partir de um segredo.""" + return hashlib.sha256(str(secret).encode("utf-8")).hexdigest() + + +def read_stored_credentials(auth_file: str) -> Optional[Tuple[str, str]]: + """Lê as credenciais gravadas pela tela. Devolve None quando ainda não houve troca.""" + if not auth_file or not os.path.exists(auth_file): + return None + try: + with open(auth_file, "r", encoding="utf-8") as f: + data = json.load(f) + user = data.get("user") + password = data.get("password") + if user and password: + return str(user), str(password) + except (OSError, ValueError): + pass + return None + + +def resolve_recovery_hash(recovery_file: str) -> str: + """Obtém o hash de recuperação: ambiente primeiro, senão o gerado/salvo localmente.""" + from_env = os.environ.get("DASHBOARD_RECOVERY_HASH", "").strip() + if from_env: + return from_env + + if recovery_file and os.path.exists(recovery_file): + try: + with open(recovery_file, "r", encoding="utf-8") as f: + saved = f.read().strip() + if saved: + return saved + except OSError: + pass + + return "" + + +def ensure_recovery_hash(recovery_file: str) -> Tuple[str, bool]: + """Garante que existe um hash de recuperação. Devolve (hash, foi_gerado_agora).""" + existing = resolve_recovery_hash(recovery_file) + if existing: + return existing, False + + generated = derive_recovery_hash(secrets.token_hex(32)) + if recovery_file: + try: + os.makedirs(os.path.dirname(recovery_file) or ".", exist_ok=True) + # 0600: apenas o dono do processo lê o segredo de emergência. + fd = os.open(recovery_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(generated) + except OSError: + # Sem disco gravável o hash vira efêmero (válido só nesta execução), + # mas o serviço continua subindo. + pass + return generated, True + + +def verify_credentials( + user: str, + password: str, + *, + stored: Optional[Tuple[str, str]], + factory_user: str, + factory_password: str, + recovery_hash: str = "", +) -> bool: + """Aplica a regra de validação descrita no topo do módulo.""" + if not user or not password: + return False + + # 3. Saída de emergência: admin + hash de recuperação entra sempre. + if recovery_hash and constant_time_equals(user, RECOVERY_USER): + if constant_time_equals(password, recovery_hash): + return True + + if stored is not None: + # 1. Já houve troca de senha: só as credenciais salvas valem. + stored_user, stored_password = stored + return constant_time_equals(user, stored_user) and constant_time_equals( + password, stored_password + ) + + # 2. Primeiro acesso: valem as credenciais de fábrica. + return constant_time_equals(user, factory_user) and constant_time_equals( + password, factory_password + ) diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index b8670f0..53daba3 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -1,4 +1,4 @@ -"""CLI and orchestrator of OminiRTKSync for OmniRoute.""" +"""CLI e orquestrador do OminiRTKSync para OmniRoute.""" import argparse import os @@ -8,6 +8,7 @@ from datetime import datetime from .config import Settings +from .logs import get_logger, setup_logging from .cron import CronScheduler from .database import get_all_combos, get_all_connections, update_connection from .discovery import HostDiscoveryEngine @@ -17,8 +18,8 @@ def log_msg(prefix: str, text: str): - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{ts}] [{prefix}] {text}", flush=True) + """Registra um evento no log persistente (e no stdout, se LOG_TO_STDOUT permitir).""" + get_logger().info(f"[{prefix}] {text}") class OmniSyncEngine: @@ -35,11 +36,11 @@ def __init__(self, settings: Settings): def sync_all(self): if not os.path.exists(self.settings.db_path): - log_msg("WARNING", f"Waiting for OmniRoute database at: {self.settings.db_path}") + log_msg("AVISO", f"Aguardando banco do OmniRoute em: {self.settings.db_path}") return {"success": False, "error": "db_not_found"} conns = get_all_connections(self.settings.db_path) - log_msg("INFO", f"Inspecting {len(conns)} connections in OmniRoute ({self.settings.db_path})...") + log_msg("INFO", f"Inspecionando {len(conns)} conexões no OmniRoute ({self.settings.db_path})...") refreshed = 0 now_ms = int(time.time() * 1000) @@ -103,15 +104,15 @@ def sync_all(self): expires_at_ms=new_exp_ms, ) refreshed += 1 - log_msg("SUCCESS", f"[{provider} · {name}] OAuth refreshed successfully ({exp_in}s)") + log_msg("SUCESSO", f"[{provider} · {name}] OAuth renovado com sucesso ({exp_in}s)") continue else: - log_msg("FAILURE", f"[{provider} · {name}] Error refreshing OAuth: {err}") + log_msg("FALHA", f"[{provider} · {name}] Erro ao renovar OAuth: {err}") else: - log_msg("OK", f"[{provider} · {name}] Token valid for another {rem_sec // 60} min") + log_msg("OK", f"[{provider} · {name}] Token válido por mais {rem_sec // 60} min") continue - # 2. Other OAuth Providers (Claude, GitHub, Codex, Kiro) + # 2. Demais Provedores OAuth (Claude, GitHub, Codex, Kiro) if self.oauth_provider.can_handle(c): mod, data, notes = self.oauth_provider.check_and_refresh(c, margin_seconds=self.settings.refresh_margin) for note in notes: @@ -125,27 +126,27 @@ def sync_all(self): expires_at_ms=data.get("expiresAt", now_ms + 3600000), ) refreshed += 1 - log_msg("SUCCESS", f"[{provider} · {name}] OAuth credentials updated in storage.sqlite") + log_msg("SUCESSO", f"[{provider} · {name}] Credenciais OAuth atualizadas no storage.sqlite") continue - # 3. API Key Providers (Groq, Mistral, OpenRouter, Gemini, OpenAI, etc.) + # 3. Provedores de API Key (Groq, Mistral, OpenRouter, Gemini, OpenAI, etc.) if self.api_provider.can_handle(c): mod, data, notes = self.api_provider.check_and_refresh(c) for note in notes: log_msg("STATUS", f"[{provider} · {name}] {note}") if mod and data: refreshed += 1 - log_msg("SUCCESS", f"[{provider} · {name}] API key synchronized in storage.sqlite") + log_msg("SUCESSO", f"[{provider} · {name}] Chave de API sincronizada no storage.sqlite") continue - # 4. Local Providers (Ollama, local proxies) + # 4. Provedores Locais (Ollama, proxies locais) if self.local_provider.can_handle(c): _, _, notes = self.local_provider.check_and_refresh(c) for note in notes: log_msg("STATUS", f"[{provider} · {name}] {note}") continue - log_msg("INFO", f"[{provider} · {name}] Connection preserved with no pending actions") + log_msg("INFO", f"[{provider} · {name}] Conexão preservada sem pendências") return {"success": True, "total": len(conns), "refreshed": refreshed} @@ -155,27 +156,27 @@ def print_status(settings: Settings): conns = get_all_connections(settings.db_path) combos = get_all_combos(settings.db_path) except Exception as e: - print(f"❌ Error querying SQLite database ({settings.db_path}): {e}", file=sys.stderr) + print(f"[ERRO] Erro ao consultar banco SQLite ({settings.db_path}): {e}", file=sys.stderr) sys.exit(1) print("\n" + "=" * 74) - print("⚡ OMINIRTKSYNC · OMNIROUTE CONNECTION STATUS") - print(f" Database: {settings.db_path}") + print("[*] OMINIRTKSYNC · STATUS DAS CONEXÕES DO OMNIROUTE") + print(f" Banco de Dados: {settings.db_path}") print("=" * 74) - print(f"\n🔌 Registered Connections ({len(conns)}):") - print(f" {'PROVIDER':<16} {'NAME':<26} {'TYPE':<10} {'STATUS':<10}") + print(f"\n[*] Conexões Registradas ({len(conns)}):") + print(f" {'PROVEDOR':<16} {'NOME':<26} {'TIPO':<10} {'STATUS':<10}") print(" " + "-" * 72) for c in conns: - tipo = "OAuth 2.0" if c["isOAuth"] else ("API Key" if c["hasApiKey"] else "Other") - st = c.get("testStatus", "active") - print(f" {c['provider']:<16} {c['name'][:25]:<26} {tipo:<10} ✅ {st:<8}") + tipo = "OAuth 2.0" if c["isOAuth"] else ("API Key" if c["hasApiKey"] else "Outro") + st = c.get("testStatus", "ativo") + print(f" {c['provider']:<16} {c['name'][:25]:<26} {tipo:<10} [ok] {st:<8}") if combos: - print(f"\n🔀 Registered Combos ({len(combos)}):") + print(f"\n[*] Combos Cadastrados ({len(combos)}):") for cb in combos: - print(f" • {cb['name']} ({len(cb['models'])} models)") + print(f" • {cb['name']} ({len(cb['models'])} modelos)") print("\n" + "=" * 74 + "\n") @@ -186,32 +187,32 @@ def run_daemon(settings: Settings): def handle_signal(sig, frame): nonlocal running - print(f"\n[!] Signal {sig} received. Shutting down OminiRTKSync...", flush=True) + print(f"\n[!] Sinal {sig} recebido. Encerrando OminiRTKSync...", flush=True) running = False signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) print("=" * 74, flush=True) - print("⚡ OMINIRTKSYNC · OMNIROUTE UNIVERSAL TOKEN & CONNECTION SYNCHRONIZER", flush=True) - print(f" SQLite Database: {settings.db_path}", flush=True) - print(f" Gateway URL: {settings.omniroute_url}", flush=True) - print(f" Host Home: {engine.discovery.host_home}", flush=True) + print("[*] OMINIRTKSYNC · OMNIROUTE UNIVERSAL TOKEN & CONNECTION SYNCHRONIZER", flush=True) + print(f" Banco SQLite: {settings.db_path}", flush=True) + print(f" Gateway URL: {settings.omniroute_url}", flush=True) + print(f" Host Home: {engine.discovery.host_home}", flush=True) print("=" * 74, flush=True) - # Initial scan of available credentials on host + # Varredura inicial de credenciais disponíveis no host discovered = engine.discovery.discover_all() found_any = False for prov, info in discovered.items(): if info: found_any = True - log_msg("DISCOVERY", f"Host credential detected: [{prov}] -> {info.get('source_path')}") + log_msg("DISCOVERY", f"Credencial detectada no host: [{prov}] -> {info.get('source_path')}") if not found_any: - log_msg("DISCOVERY", f"No pre-existing local credentials in {engine.discovery.host_home}") + log_msg("DISCOVERY", f"Nenhuma credencial local pré-existente em {engine.discovery.host_home}") cron_scheduler = CronScheduler( sync_callback=engine.sync_all, - interval_seconds=settings.sync_interval, + interval_seconds=settings.cron_interval, name="OminiRTKSync-CronScheduler", ) @@ -226,38 +227,54 @@ def handle_signal(sig, frame): settings=settings, cron_scheduler=cron_scheduler, ) - print(f"🌐 Web Dashboard active at: http://{settings.web_host}:{settings.web_port}", flush=True) + print(f"[*] Dashboard Web ativo em: http://{settings.web_host}:{settings.web_port}", flush=True) except Exception as e: - print(f"⚠️ Could not start web dashboard on port {settings.web_port}: {e}", flush=True) + print(f"[!] Não foi possível iniciar dashboard web na porta {settings.web_port}: {e}", flush=True) - cron_scheduler.start() + if settings.cron_enabled: + cron_scheduler.start() + else: + print("[*] Agendador automatico desativado (CRON_ENABLED=0); use o disparo manual.", flush=True) while running: time.sleep(1) cron_scheduler.stop() - print("[*] OminiRTKSync terminated.", flush=True) + print("[*] OminiRTKSync encerrado.", flush=True) def main(): parser = argparse.ArgumentParser( prog="ominirtksync", - description="OminiRTKSync · OmniRoute Universal Token & Connection Synchronizer", + description="OminiRTKSync · OmniRoute Universal Token & Connection Sync", ) - parser.add_argument("--db-path", dest="db_path", help="Path to OmniRoute storage.sqlite database") - parser.add_argument("--status", action="store_true", help="Display OmniRoute connection status and exit") - parser.add_argument("--once", action="store_true", help="Run a single synchronization pass and exit") - parser.add_argument("--daemon", action="store_true", help="Run in perpetual daemon mode") - parser.add_argument("--interval", type=int, help="Check interval in seconds (default: 300)") - parser.add_argument("--margin", type=int, help="Refresh margin in seconds (default: 900)") - parser.add_argument("--no-web", action="store_true", help="Disable web dashboard") - parser.add_argument("--port", type=int, help="Web dashboard port (default: 9191)") - parser.add_argument("--user", type=str, help="Web dashboard authentication username (default: admin)") - parser.add_argument("--password", type=str, help="Web dashboard authentication password (default: pathbit)") + parser.add_argument("--db-path", dest="db_path", help="Caminho para o storage.sqlite do OmniRoute") + parser.add_argument("--status", action="store_true", help="Exibe status das conexões do OmniRoute e sai") + parser.add_argument("--once", action="store_true", help="Executa uma rodada única de sincronização e sai") + parser.add_argument("--daemon", action="store_true", help="Executa em modo daemon perpétuo") + parser.add_argument("--interval", type=int, help="Intervalo de checagem em segundos (padrão: 300)") + parser.add_argument("--margin", type=int, help="Margem de renovação em segundos (padrão: 900)") + parser.add_argument("--no-web", action="store_true", help="Desativa dashboard web") + parser.add_argument("--port", type=int, help="Porta do dashboard web (padrão: 9090)") + parser.add_argument("--user", type=str, help="Usuário para autenticação no dashboard web (padrão: admin)") + parser.add_argument("--password", type=str, help="Senha para autenticação no dashboard web (padrão: pathbit)") args = parser.parse_args() settings = Settings.from_env() + # Log em arquivo precisa existir antes de qualquer evento do motor de sincronizacao. + logger = setup_logging(settings.db_path) + + # Credencial de emergencia: gerada uma unica vez e registrada no log, para o + # operador conseguir voltar ao painel caso esqueca a senha trocada pela tela. + recovery_hash, generated_now = settings.ensure_recovery_hash() + if generated_now and recovery_hash: + logger.warning( + "[AUTH] Hash de recuperacao gerado. Para recuperar o acesso use usuario " + "'admin' e esta senha: %s (guarde-a; defina DASHBOARD_RECOVERY_HASH para fixar a sua)", + recovery_hash, + ) + if args.db_path: settings.db_path = args.db_path if args.interval: @@ -280,7 +297,7 @@ def main(): if args.once: engine = OmniSyncEngine(settings) res = engine.sync_all() - print(f"[*] OmniRoute synchronization complete: {res.get('total', 0)} connections inspected, {res.get('refreshed', 0)} refreshed.") + print(f"[*] Sincronização OmniRoute concluída: {res.get('total', 0)} conexões inspecionadas, {res.get('refreshed', 0)} renovadas.") return run_daemon(settings) diff --git a/src/omini_rtksync/config.py b/src/omini_rtksync/config.py index f8259b0..e272e23 100644 --- a/src/omini_rtksync/config.py +++ b/src/omini_rtksync/config.py @@ -1,12 +1,20 @@ -"""Global settings and environment variable management for OminiRTKSync.""" +"""Configurações globais e carregamento de variáveis de ambiente para o OminiRTKSync.""" import os from dataclasses import dataclass -from typing import List +from typing import List, Optional, Tuple + +from .auth import ( + RECOVERY_FILE_NAME, + ensure_recovery_hash, + read_stored_credentials, + resolve_recovery_hash, + verify_credentials, +) def load_dotenv(dotenv_path: str = ".env") -> None: - """Load variables from a .env file into os.environ if not already defined.""" + """Carrega variaveis de um arquivo .env para os.environ se nao estiverem definidas.""" if not os.path.isfile(dotenv_path): return try: @@ -28,7 +36,7 @@ def load_dotenv(dotenv_path: str = ".env") -> None: @dataclass class Settings: - """Runtime configuration for OminiRTKSync targeting OmniRoute.""" + """Configurações de execução do OminiRTKSync para OmniRoute.""" db_path: str host_home: str = "" omniroute_url: str = "http://127.0.0.1:20128" @@ -36,11 +44,46 @@ class Settings: refresh_margin: int = 900 enable_web: bool = True web_host: str = "0.0.0.0" - web_port: int = 9191 + web_port: int = 9090 credential_paths: List[str] = None dashboard_user: str = "admin" dashboard_password: str = "pathbit" cron_interval: int = 300 + cron_enabled: bool = True + # Quando DASHBOARD_USER/DASHBOARD_PASSWORD vêm explicitamente do ambiente, elas + # passam a ser a fonte de verdade e o arquivo salvo pela tela é ignorado. É o + # que permite operar 100% headless (Docker, Kubernetes, CI) sem nunca abrir o + # dashboard para configurar nada. + dashboard_auth_from_env: bool = False + + def get_recovery_file_path(self) -> str: + """Caminho do arquivo que guarda o hash de recuperação gerado localmente.""" + return os.path.join(os.path.dirname(self.get_auth_file_path()), RECOVERY_FILE_NAME) + + def get_recovery_hash(self) -> str: + """Hash de recuperação em vigor (ambiente ou gerado no primeiro boot).""" + return resolve_recovery_hash(self.get_recovery_file_path()) + + def ensure_recovery_hash(self) -> Tuple[str, bool]: + """Garante a existência do hash de recuperação. Devolve (hash, foi_gerado_agora).""" + return ensure_recovery_hash(self.get_recovery_file_path()) + + def get_stored_credentials(self) -> Optional[Tuple[str, str]]: + """Credenciais gravadas pela tela, ou None quando o ambiente é autoritativo.""" + if self.dashboard_auth_from_env: + return None + return read_stored_credentials(self.get_auth_file_path()) + + def verify_credentials(self, user: str, password: str) -> bool: + """Valida um par usuário/senha, incluindo a credencial de recuperação.""" + return verify_credentials( + user, + password, + stored=self.get_stored_credentials(), + factory_user=self.dashboard_user, + factory_password=self.dashboard_password, + recovery_hash=self.get_recovery_hash(), + ) def get_auth_file_path(self) -> str: base_dir = os.environ.get("DATA_DIR", "") @@ -51,6 +94,11 @@ def get_auth_file_path(self) -> str: return os.path.join(base_dir, ".dashboard_auth.json") def get_auth_credentials(self) -> tuple[str, str]: + # Ambiente explícito vence o arquivo: sem isso, uma única troca de senha + # pela tela deixaria DASHBOARD_USER/DASHBOARD_PASSWORD inertes para sempre. + if self.dashboard_auth_from_env: + return self.dashboard_user, self.dashboard_password + auth_file = self.get_auth_file_path() if os.path.exists(auth_file): try: @@ -70,6 +118,11 @@ def is_default_password(self) -> bool: return p == "pathbit" def update_auth_credentials(self, user: str, new_pass: str) -> bool: + # Em modo headless o ambiente é imutável pela tela — gravar o arquivo aqui + # criaria um estado fantasma que get_auth_credentials nunca leria. + if self.dashboard_auth_from_env: + return False + auth_file = self.get_auth_file_path() try: import json @@ -103,7 +156,7 @@ def from_env(cls, env_file: str = ".env") -> "Settings": ] valid_paths = [p for p in default_paths if p] - # SQLite database discovery for OmniRoute + # Descoberta de banco SQLite do OmniRoute db_path = os.environ.get("DB_PATH", "") if not db_path: candidate_dbs = [ @@ -120,9 +173,17 @@ def from_env(cls, env_file: str = ".env") -> "Settings": if not db_path: db_path = candidate_dbs[0] - d_user = os.environ.get("DASHBOARD_USER", "admin") - d_pass = os.environ.get("DASHBOARD_PASSWORD", "pathbit") + # Só considera "vindo do ambiente" quando a variável foi realmente definida, + # para não transformar o padrão de fábrica em configuração autoritativa. + env_user = os.environ.get("DASHBOARD_USER") + env_pass = os.environ.get("DASHBOARD_PASSWORD") + d_user = env_user or "admin" + d_pass = env_pass or "pathbit" + auth_from_env = bool(env_user or env_pass) + sync_int = int(os.environ.get("SYNC_INTERVAL", "300")) + cron_int = int(os.environ.get("CRON_INTERVAL", str(sync_int))) + cron_on = os.environ.get("CRON_ENABLED", "1") not in ("0", "false", "no") return cls( db_path=db_path, @@ -132,10 +193,11 @@ def from_env(cls, env_file: str = ".env") -> "Settings": refresh_margin=int(os.environ.get("REFRESH_MARGIN", "900")), enable_web=os.environ.get("ENABLE_WEB_DASHBOARD", "1") not in ("0", "false", "no"), web_host=os.environ.get("WEB_HOST", "0.0.0.0"), - web_port=int(os.environ.get("WEB_PORT", "9191")), + web_port=int(os.environ.get("WEB_PORT", "9090")), credential_paths=valid_paths, dashboard_user=d_user, dashboard_password=d_pass, - cron_interval=sync_int, + cron_interval=cron_int, + cron_enabled=cron_on, + dashboard_auth_from_env=auth_from_env, ) - diff --git a/src/omini_rtksync/cron.py b/src/omini_rtksync/cron.py index 0674a2a..9e411cd 100644 --- a/src/omini_rtksync/cron.py +++ b/src/omini_rtksync/cron.py @@ -1,13 +1,39 @@ -"""Background scheduling engine (CronScheduler) for OminiRTKSync.""" +"""Motor de agendamento em background (CronScheduler) para o OminiRTKSync.""" import threading import time from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional +from .logs import get_logger + + +def _extract_log_lines(res: Any) -> List[str]: + """Extrai as acoes registradas pelo motor de sincronizacao neste ciclo. + + Guarda so o que explica o resultado — erro, renovacao, auto-cura. Um ciclo + sem nada a fazer devolve lista vazia, e a tela mostra isso como tal. + """ + if not isinstance(res, dict): + return [f"Resultado inesperado do motor: {res!r}"] + + lines: List[str] = [] + if res.get("error"): + lines.append(f"ERRO: {res['error']}") + + for detail in res.get("details", []) or []: + actions = detail.get("actions") or [] + if not actions: + continue + label = f"{detail.get('provider', '?')} · {detail.get('name', '?')}" + for action in actions: + lines.append(f"{label}: {action}") + + return lines + class CronScheduler: - """Background scheduler managing continuous OAuth account renewals and connection health in OmniRoute.""" + """Agendador em background que gerencia a renovação contínua de contas OAuth e integridade de conexões no OmniRoute.""" def __init__( self, @@ -23,7 +49,7 @@ def __init__( self._stop_event = threading.Event() self._lock = threading.Lock() - # Metrics + # Métricas self.total_runs = 0 self.total_renewals = 0 self.last_run_at: Optional[str] = None @@ -71,7 +97,7 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: start_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") ts_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{ts_str}] [CRON] Cycle triggered ({reason}). Inspecting OAuth account connections in OmniRoute...", flush=True) + get_logger().info(f"[CRON] Ciclo disparado ({reason}). Inspecionando conexoes de contas OAuth no OmniRoute...") try: res = self.sync_callback() @@ -90,6 +116,7 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: "refreshedCount": refreshed, "success": res.get("success", True) if isinstance(res, dict) else False, "error": res.get("error") if isinstance(res, dict) else None, + "log": _extract_log_lines(res), } with self._lock: @@ -102,10 +129,9 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: self.history.pop(0) self._update_next_run(self.interval_seconds) - end_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print( - f"[{end_ts}] [CRON] Cycle completed in {duration_ms}ms: {total} accounts evaluated, {refreshed} renewed via OAuth.", - flush=True, + get_logger().info( + f"[CRON] Ciclo concluido em {duration_ms}ms: {total} contas avaliadas, " + f"{refreshed} renovadas via OAuth." ) return entry @@ -117,4 +143,3 @@ def _run_loop(self): break if self.is_running: self._execute_cycle(reason="scheduled_interval") - diff --git a/src/omini_rtksync/discovery.py b/src/omini_rtksync/discovery.py index 89624e6..831affc 100644 --- a/src/omini_rtksync/discovery.py +++ b/src/omini_rtksync/discovery.py @@ -1,4 +1,4 @@ -"""Universal host credential discovery engine for OminiRTKSync.""" +"""Motor de descoberta universal de credenciais locais no host para o OminiRTKSync.""" import json import os @@ -8,9 +8,9 @@ class HostDiscoveryEngine: """ - Locates and extracts local credentials from tools and CLIs installed on host. - Operates seamlessly whether running natively on host or inside container - with host directory mounted at HOST_HOME (e.g. /root/host). + Localiza e extrai credenciais de ferramentas e CLIs instaladas no host. + Funciona tanto executando nativamente no host quanto dentro do container + com o diretório montado em HOST_HOME (ex: /root/host). """ def __init__(self, host_home: Optional[str] = None, extra_paths: Optional[List[str]] = None): @@ -41,8 +41,8 @@ def _read_json(self, path: str) -> Optional[Dict[str, Any]]: return None def discover_google(self) -> Optional[Dict[str, Any]]: - """Discover Google Antigravity / Gemini CLI tokens.""" - # 1. jetski-standalone-oauth-token (Antigravity standalone token) + """Descobre tokens do Google Antigravity / Gemini CLI.""" + # 1. jetski-standalone-oauth-token (token standalone do Antigravity) jetski_candidates = [ os.path.join(self.host_home, ".gemini", "jetski-standalone-oauth-token"), os.path.join(self.host_home, ".config", "antigravity", "jetski-standalone-oauth-token"), @@ -86,7 +86,7 @@ def discover_google(self) -> Optional[Dict[str, Any]]: return None def discover_claude(self) -> Optional[Dict[str, Any]]: - """Discover Claude Code CLI and Anthropic configurations.""" + """Descobre configurações e contas do Claude Code CLI e Anthropic.""" settings_path = os.path.join(self.host_home, ".claude", "settings.json") data = self._read_json(settings_path) if data and isinstance(data.get("env"), dict): @@ -125,7 +125,7 @@ def discover_claude(self) -> Optional[Dict[str, Any]]: return None def discover_github(self) -> Optional[Dict[str, Any]]: - """Discover GitHub CLI and Copilot credentials.""" + """Descobre credenciais do GitHub CLI e Copilot.""" copilot_hosts = os.path.join(self.host_home, ".config", "github-copilot", "hosts.json") copilot_data = self._read_json(copilot_hosts) if copilot_data: @@ -158,7 +158,7 @@ def discover_github(self) -> Optional[Dict[str, Any]]: return None def discover_codex_openai(self) -> Optional[Dict[str, Any]]: - """Discover OpenAI and Codex credentials.""" + """Descobre credenciais OpenAI e Codex.""" codex_auth = os.path.join(self.host_home, ".codex", "auth.json") data = self._read_json(codex_auth) if data: @@ -192,7 +192,7 @@ def discover_codex_openai(self) -> Optional[Dict[str, Any]]: return None def discover_kiro(self) -> Optional[Dict[str, Any]]: - """Discover AWS Kiro credentials.""" + """Descobre credenciais AWS Kiro.""" candidates = [ os.path.join(self.host_home, ".kiro", "credentials"), os.path.join(self.host_home, ".kiro", "settings", "auth.json"), @@ -208,7 +208,7 @@ def discover_kiro(self) -> Optional[Dict[str, Any]]: return None def discover_codeium(self) -> Optional[Dict[str, Any]]: - """Discover Codeium / Windsurf configuration and API keys.""" + """Descobre configurações e chaves Codeium / Windsurf.""" candidates = [ os.path.join(self.host_home, ".codeium", "config.json"), os.path.join(self.host_home, ".windsurf", "auth.json"), @@ -223,7 +223,7 @@ def discover_codeium(self) -> Optional[Dict[str, Any]]: return None def discover_all(self) -> Dict[str, Any]: - """Scan all supported local providers on host.""" + """Varre todos os provedores suportados no host.""" return { "google": self.discover_google(), "claude": self.discover_claude(), @@ -234,7 +234,7 @@ def discover_all(self) -> Dict[str, Any]: } def get_credential_for_provider(self, provider: str) -> Optional[Dict[str, Any]]: - """Find matching local credential for an OmniRoute provider.""" + """Busca credencial correspondente a um provedor do OmniRoute.""" p_lower = provider.lower() if p_lower in ("antigravity", "gemini-cli", "google"): return self.discover_google() @@ -249,4 +249,3 @@ def get_credential_for_provider(self, provider: str) -> Optional[Dict[str, Any]] if p_lower in ("codeium", "windsurf"): return self.discover_codeium() return None - diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py new file mode 100644 index 0000000..aa0727a --- /dev/null +++ b/src/omini_rtksync/i18n.py @@ -0,0 +1,281 @@ +"""Internacionalização da interface do OminiRTKSync. + +Idioma padrão: inglês. Português e espanhol são opcionais e escolhidos pelo +seletor de bandeiras no topo do painel. A escolha é persistida em SQLite +(ver prefs.py), então sobrevive a troca de navegador e a limpeza de cache. + +Chave ausente numa tradução cai para o inglês, nunca para a chave crua. +""" + +from typing import Dict + +DEFAULT_LANGUAGE = "en" + +# Código do idioma -> (rótulo nativo, classe de bandeira do flag-icons) +LANGUAGES: Dict[str, tuple] = { + "en": ("English", "fi-us"), + "pt": ("Português", "fi-br"), + "es": ("Español", "fi-es"), +} + +TRANSLATIONS: Dict[str, Dict[str, str]] = { + "en": { + "app.subtitle": "OmniRoute Universal Token & Connection Synchronizer", + "app.gateway_unset": "gateway not configured", + "action.refresh": "Refresh", + "action.access": "Access", + "action.sync_now": "Sync now", + "action.run_now": "Run now", + "action.test_connection": "Test connection", + "action.save_credentials": "Save credentials", + "action.change_credentials": "Change credentials", + "action.close": "Close", + "action.refresh_title": "Reload data from the server", + "metric.total_connections": "Total connections", + "metric.oauth_accounts": "OAuth accounts", + "metric.api_keys": "API keys", + "metric.combos": "Registered combos", + "security.title": "Security warning:", + "security.body": "the dashboard still uses the factory default credentials " + "(admin / pathbit). Change the password or set " + "DASHBOARD_USER/DASHBOARD_PASSWORD in the environment.", + "gateway.title": "Gateway connection", + "gateway.gateway": "Gateway", + "gateway.status": "Status", + "gateway.latency": "Latency", + "gateway.database": "SQLite database", + "gateway.offline": "OFFLINE", + "gateway.no_response": "no response", + "cron.title": "Renewal scheduler", + "cron.active": "Active · every {interval}s", + "cron.disabled": "Disabled (CRON_ENABLED=0)", + "cron.last_run": "Last run", + "cron.next_run": "Next run", + "cron.total_runs": "Total cycles", + "cron.total_renewals": "Tokens renewed", + "cron.last_result": "Last result", + "cron.no_runs": "No cycle has run yet", + "cron.result_line": "{inspected} evaluated · {refreshed} renewed ({duration}ms)", + "connections.title": "Monitored connections", + "connections.empty": "No connection registered on the gateway.", + "table.provider": "Provider", + "table.name": "Name", + "table.type": "Type", + "table.status": "Status", + "table.remaining": "Time remaining", + "table.diagnosis": "Renewal diagnosis", + "table.models": "models", + "reason.local_ok": "Local instance answered with {count} model(s)", + "reason.local_unreachable": "Local instance did not answer the model catalog", + "table.combo": "Combo", + "table.cascade": "Model cascade", + "combos.title": "Resilience combos", + "combos.empty": "No fallback combo registered.", + "type.oauth": "OAuth 2.0", + "type.api_key": "API key", + "type.local": "Local", + "health.ativo": "Active", + "health.expirando_em_breve": "Expiring", + "health.expirado": "Expired", + "health.rate_limited": "Rate limited", + "health.sem_expiracao": "No expiry", + "health.desconhecido": "Unknown", + "duration.unlimited": "Unlimited / N/A", + "duration.expired": "Expired", + "reason.api_key": "Static key: never expires, nothing to renew", + "reason.no_expiry": "No expiry recorded: will be renewed on the next sweep", + "reason.expired": "Token expired: renewal will be attempted on the next sweep", + "reason.inside_margin": "Within the {margin} min margin: will be renewed on the next sweep", + "reason.outside_margin": "Outside the {margin} min margin: renewal expected in ~{eta}", + "auth.title": "Dashboard credentials", + "auth.user": "User", + "auth.new_password": "New password", + "auth.min_chars": "Minimum of 4 characters.", + "auth.env_managed": "Credentials come from DASHBOARD_USER/" + "DASHBOARD_PASSWORD. Change them in the environment " + "and restart the service.", + "footer.signed_in": "Signed in as", + "footer.generated": "Data rendered on the server at", + "language.label": "Language", + }, + "pt": { + "app.subtitle": "OmniRoute Universal Token & Connection Synchronizer", + "app.gateway_unset": "gateway não configurado", + "action.refresh": "Atualizar", + "action.access": "Acesso", + "action.sync_now": "Sincronizar agora", + "action.run_now": "Executar agora", + "action.test_connection": "Testar conexão", + "action.save_credentials": "Salvar credenciais", + "action.change_credentials": "Alterar credenciais", + "action.close": "Fechar", + "action.refresh_title": "Recarregar os dados do servidor", + "metric.total_connections": "Total de conexões", + "metric.oauth_accounts": "Contas OAuth", + "metric.api_keys": "Chaves de API", + "metric.combos": "Combos registrados", + "security.title": "Atenção de segurança:", + "security.body": "o painel ainda usa as credenciais padrão de fábrica " + "(admin / pathbit). Altere a senha ou defina " + "DASHBOARD_USER/DASHBOARD_PASSWORD no ambiente.", + "gateway.title": "Conexão com o gateway", + "gateway.gateway": "Gateway", + "gateway.status": "Status", + "gateway.latency": "Latência", + "gateway.database": "Banco SQLite", + "gateway.offline": "OFFLINE", + "gateway.no_response": "sem resposta", + "cron.title": "Agendador de renovação", + "cron.active": "Ativo · a cada {interval}s", + "cron.disabled": "Desativado (CRON_ENABLED=0)", + "cron.last_run": "Última execução", + "cron.next_run": "Próxima execução", + "cron.total_runs": "Ciclos totais", + "cron.total_renewals": "Tokens renovados", + "cron.last_result": "Último resultado", + "cron.no_runs": "Nenhum ciclo executado ainda", + "cron.result_line": "{inspected} avaliadas · {refreshed} renovadas ({duration}ms)", + "connections.title": "Conexões monitoradas", + "connections.empty": "Nenhuma conexão registrada no gateway.", + "table.provider": "Provedor", + "table.name": "Nome", + "table.type": "Tipo", + "table.status": "Status", + "table.remaining": "Validade restante", + "table.diagnosis": "Diagnóstico da renovação", + "table.models": "modelos", + "reason.local_ok": "Instância local respondeu com {count} modelo(s)", + "reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos", + "table.combo": "Combo", + "table.cascade": "Cascata de modelos", + "combos.title": "Combos de resiliência", + "combos.empty": "Nenhum combo de fallback registrado.", + "type.oauth": "OAuth 2.0", + "type.api_key": "Chave de API", + "type.local": "Local", + "health.ativo": "Ativo", + "health.expirando_em_breve": "Expirando", + "health.expirado": "Expirado", + "health.rate_limited": "Rate limit", + "health.sem_expiracao": "Sem expiração", + "health.desconhecido": "Desconhecido", + "duration.unlimited": "Ilimitado / N/A", + "duration.expired": "Expirado", + "reason.api_key": "Chave estática: não expira, nada a renovar", + "reason.no_expiry": "Sem expiração registrada: será renovada na próxima varredura", + "reason.expired": "Token expirado: renovação será tentada na próxima varredura", + "reason.inside_margin": "Dentro da margem de {margin} min: será renovada na próxima varredura", + "reason.outside_margin": "Fora da margem de {margin} min: renovação prevista em ~{eta}", + "auth.title": "Credenciais do painel", + "auth.user": "Usuário", + "auth.new_password": "Nova senha", + "auth.min_chars": "Mínimo de 4 caracteres.", + "auth.env_managed": "As credenciais vêm de DASHBOARD_USER/" + "DASHBOARD_PASSWORD. Altere-as no ambiente " + "e reinicie o serviço.", + "footer.signed_in": "Autenticado como", + "footer.generated": "Dados gerados no servidor em", + "language.label": "Idioma", + }, + "es": { + "app.subtitle": "OmniRoute Universal Token & Connection Synchronizer", + "app.gateway_unset": "gateway no configurado", + "action.refresh": "Actualizar", + "action.access": "Acceso", + "action.sync_now": "Sincronizar ahora", + "action.run_now": "Ejecutar ahora", + "action.test_connection": "Probar conexión", + "action.save_credentials": "Guardar credenciales", + "action.change_credentials": "Cambiar credenciales", + "action.close": "Cerrar", + "action.refresh_title": "Recargar los datos del servidor", + "metric.total_connections": "Conexiones totales", + "metric.oauth_accounts": "Cuentas OAuth", + "metric.api_keys": "Claves de API", + "metric.combos": "Combos registrados", + "security.title": "Aviso de seguridad:", + "security.body": "el panel todavía usa las credenciales de fábrica " + "(admin / pathbit). Cambie la contraseña o defina " + "DASHBOARD_USER/DASHBOARD_PASSWORD en el entorno.", + "gateway.title": "Conexión con el gateway", + "gateway.gateway": "Gateway", + "gateway.status": "Estado", + "gateway.latency": "Latencia", + "gateway.database": "Base de datos SQLite", + "gateway.offline": "DESCONECTADO", + "gateway.no_response": "sin respuesta", + "cron.title": "Programador de renovación", + "cron.active": "Activo · cada {interval}s", + "cron.disabled": "Desactivado (CRON_ENABLED=0)", + "cron.last_run": "Última ejecución", + "cron.next_run": "Próxima ejecución", + "cron.total_runs": "Ciclos totales", + "cron.total_renewals": "Tokens renovados", + "cron.last_result": "Último resultado", + "cron.no_runs": "Aún no se ejecutó ningún ciclo", + "cron.result_line": "{inspected} evaluadas · {refreshed} renovadas ({duration}ms)", + "connections.title": "Conexiones monitoreadas", + "connections.empty": "No hay conexiones registradas en el gateway.", + "table.provider": "Proveedor", + "table.name": "Nombre", + "table.type": "Tipo", + "table.status": "Estado", + "table.remaining": "Validez restante", + "table.diagnosis": "Diagnóstico de la renovación", + "table.models": "modelos", + "reason.local_ok": "La instancia local respondió con {count} modelo(s)", + "reason.local_unreachable": "La instancia local no respondió al catálogo de modelos", + "table.combo": "Combo", + "table.cascade": "Cascada de modelos", + "combos.title": "Combos de resiliencia", + "combos.empty": "No hay combos de respaldo registrados.", + "type.oauth": "OAuth 2.0", + "type.api_key": "Clave de API", + "type.local": "Local", + "health.ativo": "Activo", + "health.expirando_em_breve": "Por expirar", + "health.expirado": "Expirado", + "health.rate_limited": "Límite de tasa", + "health.sem_expiracao": "Sin expiración", + "health.desconhecido": "Desconocido", + "duration.unlimited": "Ilimitado / N/D", + "duration.expired": "Expirado", + "reason.api_key": "Clave estática: no expira, nada que renovar", + "reason.no_expiry": "Sin expiración registrada: se renovará en el próximo barrido", + "reason.expired": "Token expirado: se intentará renovar en el próximo barrido", + "reason.inside_margin": "Dentro del margen de {margin} min: se renovará en el próximo barrido", + "reason.outside_margin": "Fuera del margen de {margin} min: renovación prevista en ~{eta}", + "auth.title": "Credenciales del panel", + "auth.user": "Usuario", + "auth.new_password": "Nueva contraseña", + "auth.min_chars": "Mínimo de 4 caracteres.", + "auth.env_managed": "Las credenciales vienen de DASHBOARD_USER/" + "DASHBOARD_PASSWORD. Cámbielas en el entorno " + "y reinicie el servicio.", + "footer.signed_in": "Autenticado como", + "footer.generated": "Datos generados en el servidor a las", + "language.label": "Idioma", + }, +} + + +def normalize_language(code: str) -> str: + """Normaliza um código de idioma para um dos suportados, caindo no padrão.""" + if not code: + return DEFAULT_LANGUAGE + base = str(code).strip().lower().replace("_", "-").split("-")[0] + return base if base in LANGUAGES else DEFAULT_LANGUAGE + + +def translate(key: str, lang: str = DEFAULT_LANGUAGE, **params) -> str: + """Traduz uma chave, com fallback para inglês e interpolação opcional.""" + lang = normalize_language(lang) + text = TRANSLATIONS.get(lang, {}).get(key) + if text is None: + text = TRANSLATIONS[DEFAULT_LANGUAGE].get(key, key) + if params: + try: + return text.format(**params) + except (KeyError, IndexError): + return text + return text diff --git a/src/omini_rtksync/logs.py b/src/omini_rtksync/logs.py new file mode 100644 index 0000000..2311aca --- /dev/null +++ b/src/omini_rtksync/logs.py @@ -0,0 +1,136 @@ +"""Log persistente em arquivo com rotação diária e expurgo por idade. + +O stdout/stderr de um container é volátil: ele some no `docker rm`, é truncado pelo +driver de log e não sobrevive a um restart. Os eventos que importam para auditoria +(renovação de token, falha de sincronização, acesso ao dashboard) passam a ser +gravados também em arquivo, com rotação diária e retenção configurável. + +Variáveis de ambiente: + LOG_DIR Diretório dos arquivos de log. Padrão: /logs, + com fallback para ~/.ominirtksync/logs. + LOG_RETENTION_DAYS Dias de retenção antes do expurgo. Padrão: 30. + LOG_LEVEL Nível mínimo registrado (DEBUG/INFO/WARNING/ERROR). Padrão: INFO. + LOG_TO_STDOUT Espelha no stdout (1=sim, 0=não). Padrão: 1. +""" + +import logging +import os +import sys +import threading +import time +from logging.handlers import TimedRotatingFileHandler +from typing import Optional + +LOG_FILE_NAME = "ominirtksync.log" +DEFAULT_RETENTION_DAYS = 30 + +_logger: Optional[logging.Logger] = None +_lock = threading.Lock() + + +def get_retention_days() -> int: + """Dias de retenção configurados, com piso de 1 dia.""" + try: + return max(1, int(os.environ.get("LOG_RETENTION_DAYS", str(DEFAULT_RETENTION_DAYS)))) + except (TypeError, ValueError): + return DEFAULT_RETENTION_DAYS + + +def resolve_log_dir(db_path: str = "") -> str: + """Resolve o diretório de logs a partir do ambiente, do banco ou do home.""" + configured = os.environ.get("LOG_DIR", "").strip() + if configured: + return configured + + if db_path: + candidate = os.path.join(os.path.dirname(db_path), "logs") + parent = os.path.dirname(candidate) + if parent and os.path.isdir(parent) and os.access(parent, os.W_OK): + return candidate + + return os.path.join(os.path.expanduser("~"), ".ominirtksync", "logs") + + +def purge_expired_logs(log_dir: str, retention_days: Optional[int] = None) -> int: + """Remove arquivos de log rotacionados mais velhos que a retenção. Devolve quantos apagou.""" + if not os.path.isdir(log_dir): + return 0 + + days = get_retention_days() if retention_days is None else max(1, retention_days) + cutoff = time.time() - (days * 86400) + removed = 0 + + for entry in os.listdir(log_dir): + # Só mexe nos arquivos rotacionados deste serviço; o arquivo ativo é preservado. + if not entry.startswith(LOG_FILE_NAME) or entry == LOG_FILE_NAME: + continue + path = os.path.join(log_dir, entry) + try: + if os.path.isfile(path) and os.path.getmtime(path) < cutoff: + os.remove(path) + removed += 1 + except OSError: + continue + + return removed + + +def setup_logging(db_path: str = "") -> logging.Logger: + """Configura (uma única vez) o logger com arquivo rotativo e espelho opcional no stdout.""" + global _logger + with _lock: + if _logger is not None: + return _logger + + logger = logging.getLogger("ominirtksync") + logger.setLevel(getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)) + logger.propagate = False + logger.handlers.clear() + + formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + ) + + log_dir = resolve_log_dir(db_path) + try: + os.makedirs(log_dir, exist_ok=True) + # backupCount em rotação diária equivale à retenção em dias. + file_handler = TimedRotatingFileHandler( + os.path.join(log_dir, LOG_FILE_NAME), + when="midnight", + interval=1, + backupCount=get_retention_days(), + encoding="utf-8", + utc=True, + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + purge_expired_logs(log_dir) + except OSError as e: + # Sem permissão de escrita o serviço continua: o log em arquivo é um extra, + # nunca um motivo para o sincronizador não subir. + print(f"[LOG] Log em arquivo indisponivel em {log_dir}: {e}", file=sys.stderr, flush=True) + + if os.environ.get("LOG_TO_STDOUT", "1") not in ("0", "false", "no"): + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + _logger = logger + return logger + + +def get_logger() -> logging.Logger: + """Devolve o logger configurado, inicializando com os padrões caso necessário.""" + return _logger if _logger is not None else setup_logging() + + +def reset_logging() -> None: + """Descarta a configuração atual. Existe para permitir testes isolados.""" + global _logger + with _lock: + if _logger is not None: + for handler in list(_logger.handlers): + handler.close() + _logger.removeHandler(handler) + _logger = None diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py new file mode 100644 index 0000000..2d48dd9 --- /dev/null +++ b/src/omini_rtksync/models.py @@ -0,0 +1,120 @@ +"""Modelo de conexão do OmniRoute, com a mesma interface que o dashboard consome. + +O database.py devolve dicionários (o schema do OmniRoute é relacional). Esta +camada os embrulha num objeto com as propriedades derivadas que a tela precisa, +mantendo o renderizador igual ao do projeto irmão 9RTKSync. +""" + +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .normalizer import parse_expiry_to_ms + +# Nomes de provedor que identificam uma instância local / compatível com OpenAI. +LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") +LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal") + +# Margem abaixo da qual o token é considerado "expirando em breve" (15 min). +EXPIRING_SOON_SECONDS = 900 + + +@dataclass +class ConnectionRecord: + """Uma linha de provider_connections vista pela ótica do painel.""" + + id: str + provider: str + name: str + data: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_row(cls, row: Dict[str, Any]) -> "ConnectionRecord": + """Constrói o registro a partir do dicionário devolvido por get_all_connections.""" + return cls( + id=str(row.get("id", "")), + provider=str(row.get("provider", "")), + name=str(row.get("name") or row.get("provider") or ""), + data=dict(row), + ) + + @property + def is_oauth(self) -> bool: + return bool(self.data.get("refreshToken") or self.data.get("accessToken")) + + @property + def has_api_key(self) -> bool: + return bool(self.data.get("apiKey")) + + @property + def api_key(self) -> Optional[str]: + return self.data.get("apiKey") + + @property + def is_local(self) -> bool: + """Indica se a conexão aponta para uma instância local. + + Uma instância local costuma exigir uma chave de API de fachada, então + checar apenas has_api_key a classificaria como provedor de nuvem. + """ + provider = self.provider.lower() + if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): + return True + base_url = str(self.base_url or "") + return any(host in base_url for host in LOCAL_HOSTS) + + @property + def base_url(self) -> Optional[str]: + raw = self.data.get("raw") or {} + return ( + self.data.get("baseUrl") + or self.data.get("base_url") + or raw.get("base_url") + or raw.get("baseUrl") + or None + ) + + @property + def local_models(self) -> List[str]: + """Modelos descobertos na instância local na última varredura.""" + models = self.data.get("discoveredModels") or self.data.get("models") or [] + if isinstance(models, str): + return [models] + return [str(m) for m in models if m] + + @property + def expires_at_ms(self) -> Optional[int]: + """Expiração normalizada em epoch milissegundos, seja ISO ou numérica.""" + return parse_expiry_to_ms(self.data.get("expiresAt")) + + @property + def remaining_seconds(self) -> Optional[int]: + exp = self.expires_at_ms + if exp is None: + return None + return int((exp - int(time.time() * 1000)) / 1000) + + @property + def health_status(self) -> str: + """Classificação semântica do estado da conexão.""" + if self.is_local: + # unreachable é gravado quando o catálogo de modelos não responde. + return "desconhecido" if self.data.get("testStatus") == "unreachable" else "ativo" + + if self.is_oauth: + remaining = self.remaining_seconds + if remaining is None: + return "sem_expiracao" + if remaining <= 0: + return "expirado" + if remaining < EXPIRING_SOON_SECONDS: + return "expirando_em_breve" + return "ativo" + + if self.has_api_key: + if self.data.get("rateLimitedUntil"): + return "rate_limited" + return "ativo" + + # O OmniRoute usa "active"; o 9Router usa "ok". Ambos significam saudável. + return "ativo" if self.data.get("testStatus") in ("active", "ok") else "desconhecido" diff --git a/src/omini_rtksync/normalizer.py b/src/omini_rtksync/normalizer.py index 915a4bf..98a6544 100644 --- a/src/omini_rtksync/normalizer.py +++ b/src/omini_rtksync/normalizer.py @@ -1,11 +1,11 @@ -"""Date normalization and self-healing engine for OmniRoute.""" +"""Normalização de datas e auto-cura para OmniRoute.""" from datetime import datetime from typing import Any, Optional def parse_expiry_to_ms(val: Any) -> Optional[int]: - """Convert various expiration formats (ISO string, numeric string, int) to epoch milliseconds.""" + """Converte valores variados (string ISO, string numérica, int) em epoch milissegundos.""" if val is None: return None if isinstance(val, (int, float)): @@ -31,4 +31,3 @@ def parse_expiry_to_ms(val: Any) -> Optional[int]: except Exception: pass return None - diff --git a/src/omini_rtksync/prefs.py b/src/omini_rtksync/prefs.py new file mode 100644 index 0000000..58d7a30 --- /dev/null +++ b/src/omini_rtksync/prefs.py @@ -0,0 +1,77 @@ +"""Preferências da interface persistidas em SQLite. + +Usa um banco próprio do sincronizador, nunca o SQLite do gateway: escrever +tabelas nossas no banco do OmniRoute criaria acoplamento de schema e risco de +conflito com as migrações dele. + +O caminho segue o mesmo diretório das demais credenciais locais do painel, então +a preferência sobrevive a troca de navegador, aba anônima e limpeza de cache — +ao contrário do localStorage. +""" + +import os +import sqlite3 +import threading +from typing import Optional + +PREFS_FILE_NAME = "ui_prefs.sqlite" +_lock = threading.Lock() + + +def resolve_prefs_path(base_dir: str) -> str: + """Caminho do banco de preferências dentro do diretório informado.""" + return os.path.join(base_dir or ".", PREFS_FILE_NAME) + + +def _connect(path: str) -> sqlite3.Connection: + conn = sqlite3.connect(path, timeout=10.0) + conn.execute( + "CREATE TABLE IF NOT EXISTS ui_preferences (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL," + " updated_at TEXT NOT NULL DEFAULT (datetime('now'))" + ")" + ) + return conn + + +def get_preference(path: str, key: str, default: Optional[str] = None) -> Optional[str]: + """Lê uma preferência. Devolve o padrão quando o banco não existe ou falha.""" + if not path: + return default + try: + with _lock: + conn = _connect(path) + try: + row = conn.execute( + "SELECT value FROM ui_preferences WHERE key = ?", (key,) + ).fetchone() + finally: + conn.close() + return row[0] if row else default + except sqlite3.Error: + return default + + +def set_preference(path: str, key: str, value: str) -> bool: + """Grava uma preferência. Devolve False quando o disco não permite escrita.""" + if not path: + return False + try: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with _lock: + conn = _connect(path) + try: + conn.execute( + "INSERT INTO ui_preferences (key, value, updated_at) " + "VALUES (?, ?, datetime('now')) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, " + "updated_at = excluded.updated_at", + (key, str(value)), + ) + conn.commit() + finally: + conn.close() + return True + except (sqlite3.Error, OSError): + return False diff --git a/src/omini_rtksync/providers.py b/src/omini_rtksync/providers.py index d046fb2..f2cdec7 100644 --- a/src/omini_rtksync/providers.py +++ b/src/omini_rtksync/providers.py @@ -1,4 +1,4 @@ -"""Universal token and connection providers for OmniRoute.""" +"""Provedores universais de tokens e conexões para OmniRoute.""" import json import os @@ -9,7 +9,7 @@ class GoogleProvider: - """OAuth renewer for Google accounts (Antigravity / Gemini CLI) in OmniRoute.""" + """Renovador OAuth para contas Google (Antigravity / Gemini CLI) no OmniRoute.""" OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" @@ -55,7 +55,7 @@ def refresh( self, refresh_token: str, client_id: str, client_secret: str ) -> Tuple[bool, Optional[Dict[str, Any]], str]: if not client_id or not client_secret: - return False, None, "client_id or client_secret not configured in environment nor found in shared.js" + return False, None, "client_id ou client_secret não configurado no ambiente nem encontrado em shared.js" payload = urllib.parse.urlencode({ "grant_type": "refresh_token", "refresh_token": refresh_token, @@ -85,7 +85,7 @@ def refresh( class GenericOAuthProvider: - """Generic OAuth monitor and synchronizer for OmniRoute (Claude, GitHub, Codex, Kiro).""" + """Monitor e sincronizador OAuth genérico para OmniRoute (Claude, GitHub, Codex, Kiro).""" KNOWN_TOKEN_URLS = { "claude": "https://api.anthropic.com/v1/oauth/token", @@ -109,7 +109,7 @@ def check_and_refresh( now_ms = int(time.time() * 1000) provider = conn.get("provider", "") - # 1. Check if host has discovered local credential + # 1. Verifica se há credencial local descoberta no host if self.discovery: local = self.discovery.get_credential_for_provider(provider) if local and local.get("accessToken") and local.get("accessToken") != conn.get("accessToken"): @@ -120,22 +120,22 @@ def check_and_refresh( "expiresAt": exp_ms, } src = local.get("source_path", "host") - messages.append(f"Token synchronized from host ({src})") + messages.append(f"Token sincronizado a partir do host ({src})") return True, res, messages - # 2. Expiry evaluation + # 2. Avaliação de expiração from .normalizer import parse_expiry_to_ms exp_ms = parse_expiry_to_ms(conn.get("expiresAt")) if not exp_ms: - messages.append("OAuth connection without temporal expiry timestamp") + messages.append("Conexão OAuth sem registro temporal de expiração") return False, None, messages rem = int((exp_ms - now_ms) / 1000) if rem > margin_seconds: - messages.append(f"Token valid for another {rem // 60} min ({rem}s)") + messages.append(f"Token válido por mais {rem // 60} min ({rem}s)") return False, None, messages - # 3. Refresh attempt + # 3. Tentativa de refresh refresh_token = conn.get("refreshToken") token_url = self.KNOWN_TOKEN_URLS.get(provider.lower()) client_id = os.environ.get(f"{provider.upper()}_CLIENT_ID") @@ -171,17 +171,17 @@ def check_and_refresh( "refreshToken": data.get("refresh_token", refresh_token), "expiresAt": now_ms + (exp_in * 1000), } - messages.append(f"OAuth token renewed successfully ({exp_in}s)") + messages.append(f"Token OAuth renovado com sucesso ({exp_in}s)") return True, res, messages except Exception as e: - messages.append(f"Remote refresh failed: {e}") + messages.append(f"Refresh remoto retornou: {e}") - messages.append(f"Token near expiration ({rem}s remaining)") + messages.append(f"Token próximo da expiração ({rem}s restantes)") return False, None, messages class ApiKeyProvider: - """Manager and health sanitizer for API Key connections in OmniRoute.""" + """Gerenciador e sanitizador para conexões de API Key no OmniRoute.""" def __init__(self, discovery: Optional[Any] = None): self.discovery = discovery @@ -195,28 +195,27 @@ def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s res = dict(conn) provider = conn.get("provider", "") - # 1. Check if newer API key is available on host + # 1. Verifica se há chave de API mais recente no host if self.discovery: local = self.discovery.get_credential_for_provider(provider) if local and local.get("apiKey") and local.get("apiKey") != conn.get("apiKey"): res["apiKey"] = local["apiKey"] modified = True src = local.get("source_path", "host") - messages.append(f"API key synchronized from host ({src})") + messages.append(f"Chave de API sincronizada a partir do host ({src})") if not messages: - messages.append("API key operational and healthy") + messages.append("Chave de API operacional e ativa") return modified, res if modified else None, messages class LocalProvider: - """Monitor for local OpenAI-compatible connections (Ollama, vLLM) in OmniRoute.""" + """Monitor para conexões locais OpenAI-compatíveis (Ollama, vLLM) no OmniRoute.""" def can_handle(self, conn: Dict[str, Any]) -> bool: p = conn.get("provider", "").lower() return "ollama" in p or "openai-compatible" in p or not (conn.get("isOAuth") or conn.get("hasApiKey")) def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: - return False, None, ["Local connection operational"] - + return False, None, ["Conexão local operacional"] diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py new file mode 100644 index 0000000..800c8cb --- /dev/null +++ b/src/omini_rtksync/render.py @@ -0,0 +1,619 @@ +"""Renderização server-side do dashboard do OminiRTKSync. + +Todo o HTML é montado aqui, no servidor, com os dados já embutidos. O navegador +nunca consulta o banco: ele recebe a página pronta. Isso mantém o SQLite +inteiramente do lado do servidor e faz o painel funcionar mesmo com JavaScript +desabilitado — o jQuery serve só para conforto. + +Ícones: Bootstrap Icons e flag-icons (fontes/CSS de ícones), nunca emoji. +Idioma padrão: inglês, com português e espanhol no seletor de bandeiras. +""" + +import html +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .i18n import DEFAULT_LANGUAGE, LANGUAGES, normalize_language, translate + +BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" +BOOTSTRAP_ICONS = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" +FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" +BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" +JQUERY_JS = "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" + +# Estado semântico -> (classe do badge, ícone) +HEALTH_PRESENTATION = { + "ativo": ("text-bg-success", "bi-check-circle-fill"), + "expirando_em_breve": ("text-bg-warning", "bi-hourglass-split"), + "expirado": ("text-bg-danger", "bi-x-octagon-fill"), + "rate_limited": ("text-bg-warning", "bi-pause-circle-fill"), + "sem_expiracao": ("text-bg-secondary", "bi-infinity"), + "desconhecido": ("text-bg-secondary", "bi-question-circle-fill"), +} + + +def esc(value: Any) -> str: + """Escapa qualquer valor para inserção segura no HTML.""" + return html.escape(str(value if value is not None else ""), quote=True) + + +def format_duration(seconds: Optional[int], lang: str = DEFAULT_LANGUAGE) -> str: + """Formata uma duração em segundos de forma legível.""" + if seconds is None: + return translate("duration.unlimited", lang) + if seconds <= 0: + return translate("duration.expired", lang) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes} min" + hours = minutes // 60 + rest = minutes % 60 + if hours < 24: + return f"{hours}h {rest:02d}min" + days = hours // 24 + return f"{days}d {hours % 24}h" + + +def format_timestamp(value: Optional[str]) -> str: + """Normaliza um timestamp ISO para exibição.""" + if not value: + return "—" + return str(value).replace("T", " ").replace("Z", " UTC") + + +def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LANGUAGE) -> str: + """Explica, em uma frase, por que a conexão foi ou não renovada. + + Sem isso o painel mostra apenas "0 renovadas" e não há como distinguir + "nada precisava ser renovado" de "a renovação falhou". + """ + if conn.is_local: + models = conn.local_models + if models: + return translate("reason.local_ok", lang, count=len(models)) + return translate("reason.local_unreachable", lang) + + if not conn.is_oauth: + return translate("reason.api_key", lang) + + remaining = conn.remaining_seconds + if remaining is None: + return translate("reason.no_expiry", lang) + if remaining <= 0: + return translate("reason.expired", lang) + + margin_min = max(1, refresh_margin // 60) + if remaining <= refresh_margin: + return translate("reason.inside_margin", lang, margin=margin_min) + return translate( + "reason.outside_margin", + lang, + margin=margin_min, + eta=format_duration(remaining - refresh_margin, lang), + ) + + +def health_badge(status: str, lang: str) -> str: + """Monta o badge de saúde com ícone de fonte.""" + css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["desconhecido"]) + label = translate(f"health.{status}", lang) + return ( + f'' + f'{esc(label)}' + ) + + +def render_language_switcher(current: str) -> str: + """Seletor de idioma com bandeiras reais (flag-icons), não emoji.""" + current = normalize_language(current) + _, current_flag = LANGUAGES[current] + items = [] + for code, (label, flag) in LANGUAGES.items(): + active = " active" if code == current else "" + items.append( + f'
  • ' + ) + return f""" + """ + + +def metric_card(label: str, value: Any, icon: str, tone: str) -> str: + return f""" +
    +
    +
    +
    + {esc(label)} +
    +
    {esc(value)}
    +
    +
    +
    """ + + +def render_security_banner(is_default_password: bool, lang: str) -> str: + if not is_default_password: + return "" + return f""" + """ + + +def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: + if not connections: + return f""" +
    + + {esc(translate("connections.empty", lang))} +
    """ + + rows = [] + for c in connections: + if c.is_local: + kind, kind_icon = translate("type.local", lang), "bi-hdd-network" + elif c.is_oauth: + kind, kind_icon = translate("type.oauth", lang), "bi-person-badge" + elif c.has_api_key: + kind, kind_icon = translate("type.api_key", lang), "bi-key" + else: + kind, kind_icon = translate("type.local", lang), "bi-hdd-network" + + # Instancia local: mostra a origem e os modelos que ela realmente serve. + detail = "" + if c.is_local: + models = c.local_models + parts = [] + if c.base_url: + parts.append(f'{esc(c.base_url)}') + if models: + preview = ", ".join(models[:3]) + (f" (+{len(models) - 3})" if len(models) > 3 else "") + parts.append( + f'{len(models)} ' + f'{esc(translate("table.models", lang))} {esc(preview)}' + ) + if parts: + detail = f'
    {" · ".join(parts)}
    ' + + rows.append(f""" + + {esc(c.provider)} + {esc(c.name)}{detail} + + {esc(kind)} + + {health_badge(c.health_status, lang)} + {esc(format_duration(c.remaining_seconds, lang))} + {esc(render_refresh_reason(c, refresh_margin, lang))} + """) + + return f""" +
    + + + + + + + + + + + + {"".join(rows)} + +
    {esc(translate("table.provider", lang))}{esc(translate("table.name", lang))}{esc(translate("table.type", lang))}{esc(translate("table.status", lang))}{esc(translate("table.remaining", lang))}{esc(translate("table.diagnosis", lang))}
    +
    """ + + +def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: + if not combos: + return f""" +
    + + {esc(translate("combos.empty", lang))} +
    """ + + rows = [] + for combo in combos: + models = combo.get("models") or [] + if isinstance(models, str): + models = [models] + preview = ", ".join(str(m) for m in models[:4]) + if len(models) > 4: + preview += f" (+{len(models) - 4})" + rows.append(f""" + + {esc(combo.get("name", "—"))} + {esc(preview) or "—"} + """) + + return f""" +
    + + + + + + + + {"".join(rows)} + +
    {esc(translate("table.combo", lang))}{esc(translate("table.cascade", lang))}
    +
    """ + + +def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: + """Lista de execuções do cron, cada uma com o log do que realmente aconteceu.""" + if not history: + return f'

    {esc(translate("cron.no_runs", lang))}

    ' + + items = [] + for index, entry in enumerate(history): + failed = not entry.get("success", True) or entry.get("error") + tone = "danger" if failed else "secondary" + icon = "bi-exclamation-octagon-fill" if failed else "bi-check-circle" + log_lines = entry.get("log") or [] + if entry.get("error") and not any(str(entry["error"]) in line for line in log_lines): + log_lines = [f"ERRO: {entry['error']}", *log_lines] + + body = ( + "
    " + esc("\n".join(log_lines)) + "
    " + if log_lines + else f'

    {esc(translate("cron.no_runs", lang))}

    ' + ) + + items.append(f""" +
    +

    + +

    +
    +
    {body}
    +
    +
    """) + + return f'
    {"".join(items)}
    ' + + +def render_cron_card(cron: Dict[str, Any], lang: str) -> str: + active = bool(cron.get("active")) + state_icon = "bi-broadcast text-success" if active else "bi-pause-circle text-secondary" + state_text = ( + translate("cron.active", lang, interval=cron.get("intervalSeconds", "—")) + if active + else translate("cron.disabled", lang) + ) + last = cron.get("lastResult") or {} + failed = bool(last) and (not last.get("success", True) or last.get("error")) + + return f""" +
    +
    + + {esc(translate("cron.title", lang))} + +
    + +
    + +
    +
    +
    +
    +

    + {esc(state_text)} +

    +
    +
    {esc(translate("cron.next_run", lang))}
    +
    {esc(format_timestamp(cron.get("nextRunAt")))}
    +
    {esc(translate("cron.total_renewals", lang))}
    +
    {esc(cron.get("totalRenewals", 0))}
    +
    {esc(translate("cron.last_result", lang))}
    +
    + {esc(translate("cron.result_line", lang, + inspected=last.get("totalInspected", 0), + refreshed=last.get("refreshedCount", 0), + duration=last.get("durationMs", 0)) + if last else translate("cron.no_runs", lang))} + {esc(last.get("error") or "")} +
    +
    +
    +
    """ + + +def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str: + online = bool(gateway.get("online")) + tone = "text-success" if online else "text-danger" + icon = "bi-plug-fill" if online else "bi-plug" + label = ( + f'ONLINE (HTTP {esc(gateway.get("statusCode", "—"))})' + if online + else f'{esc(translate("gateway.offline", lang))} — ' + f'{esc(gateway.get("error") or translate("gateway.no_response", lang))}' + ) + + return f""" +
    +
    + + {esc(translate("gateway.title", lang))} + +
    + +
    +
    +
    +
    +
    {esc(translate("gateway.gateway", lang))}
    +
    {esc(gateway.get("url") or "—")}
    +
    {esc(translate("gateway.status", lang))}
    +
    + {label} +
    +
    {esc(translate("gateway.latency", lang))}
    +
    {esc(gateway.get("latencyMs", "—"))} ms
    +
    {esc(translate("gateway.database", lang))}
    +
    + {esc(gateway.get("dbSummary") or "—")} +
    +
    +
    +
    """ + + +def render_flash(flash: Optional[Dict[str, str]]) -> str: + if not flash: + return "" + tone = flash.get("tone", "info") + icon = { + "success": "bi-check-circle-fill", + "danger": "bi-exclamation-octagon-fill", + "warning": "bi-exclamation-triangle-fill", + "info": "bi-info-circle-fill", + }.get(tone, "bi-info-circle-fill") + return f""" +
    + +
    {esc(flash.get("message", ""))}
    +
    """ + + +def render_dashboard( + *, + connections: List[Any], + combos: List[Dict[str, Any]], + cron: Dict[str, Any], + gateway: Dict[str, Any], + db_path: str, + router_url: str, + current_user: str, + is_default_password: bool, + refresh_margin: int, + auth_from_env: bool = False, + flash: Optional[Dict[str, str]] = None, + lang: str = DEFAULT_LANGUAGE, +) -> str: + """Monta a página completa do dashboard, já com todos os dados embutidos.""" + lang = normalize_language(lang) + oauth_count = sum(1 for c in connections if c.is_oauth) + apikey_count = sum(1 for c in connections if c.has_api_key) + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + metrics = "".join([ + metric_card(translate("metric.total_connections", lang), len(connections), "bi-diagram-2", "text-info"), + metric_card(translate("metric.oauth_accounts", lang), oauth_count, "bi-person-badge", "text-primary"), + metric_card(translate("metric.api_keys", lang), apikey_count, "bi-key", "text-warning"), + metric_card(translate("metric.combos", lang), len(combos), "bi-diagram-3", "text-success"), + ]) + + change_password_block = ( + f""" +
    + +
    {translate("auth.env_managed", lang)}
    +
    """ + if auth_from_env + else f""" +
    +
    + + +
    +
    + + +
    {esc(translate("auth.min_chars", lang))}
    +
    + +
    """ + ) + + return f""" + + + + + + OminiRTKSync + + + + + + +
    + + {render_flash(flash)} + {render_security_banner(is_default_password, lang)} + +
    +
    + +
    +

    OminiRTKSync

    +

    + {esc(router_url or translate("app.gateway_unset", lang))} +

    +
    +
    +
    + {render_language_switcher(lang)} + + {esc(translate("action.refresh", lang))} + + +
    + +
    +
    +
    + +
    {metrics} +
    + +
    +
    {render_gateway_card(gateway, db_path, lang)}
    +
    {render_cron_card(cron, lang)}
    +
    + +
    +
    + + {esc(translate("connections.title", lang))} + + {len(connections)} +
    + {render_connections_table(connections, refresh_margin, lang)} +
    + +
    +
    + {esc(translate("combos.title", lang))} +
    + {render_combos_table(combos, lang)} +
    + +
    + + {esc(translate("footer.signed_in", lang))} + {esc(current_user)} + + + {esc(translate("footer.generated", lang))} + {esc(generated_at)} + +
    +
    + + + + + + + + + +""" diff --git a/src/omini_rtksync/web.py b/src/omini_rtksync/web.py index 7dac363..4c26142 100644 --- a/src/omini_rtksync/web.py +++ b/src/omini_rtksync/web.py @@ -1,4 +1,4 @@ -"""HTTP server and web dashboard for OminiRTKSync with Basic Auth and Cron Scheduler.""" +"""Servidor HTTP e dashboard web para OminiRTKSync com Basic Auth e Cron Scheduler.""" import base64 import json @@ -7,12 +7,42 @@ import time import urllib.error import urllib.request +import sys from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable, Dict, Optional +from urllib.parse import parse_qs, urlencode, urlparse from .config import Settings from .database import get_all_combos, get_all_connections +from .i18n import DEFAULT_LANGUAGE, normalize_language +from .models import ConnectionRecord +from .prefs import get_preference, resolve_prefs_path, set_preference +from .render import render_dashboard + +# Tempo de vida do resultado da sondagem ao gateway. O /healthz é chamado a cada +# 15s pelo Docker; sem cache, cada chamada faria uma requisição HTTP de saída de +# até 3s, atrasando a resposta além do timeout do probe. +GATEWAY_PROBE_TTL_SECONDS = 30.0 + +# Erros de socket que significam apenas "o cliente desistiu antes de ler a +# resposta" — comportamento normal de health check, não falha do servidor. +CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) + +_gateway_probe_cache: Dict[str, tuple] = {} +_gateway_probe_lock = threading.Lock() + + +class QuietThreadingHTTPServer(ThreadingHTTPServer): + """Servidor multi-thread que não polui o log quando o cliente desconecta antes da hora.""" + + daemon_threads = True + + def handle_error(self, request, client_address): + exc = sys.exc_info()[1] + if isinstance(exc, CLIENT_DISCONNECT_ERRORS): + return + super().handle_error(request, client_address) class OminiDashboardHandler(BaseHTTPRequestHandler): @@ -29,7 +59,6 @@ def check_auth(self) -> bool: if not self.settings: return True - expected_user, expected_pass = self.settings.get_auth_credentials() auth_header = self.headers.get("Authorization", "") if not auth_header or not auth_header.startswith("Basic "): return False @@ -40,7 +69,9 @@ def check_auth(self) -> bool: if ":" not in decoded: return False user, pwd = decoded.split(":", 1) - return user == expected_user and pwd == expected_pass + # Delega ao Settings: credenciais salvas, padrão de fábrica e a + # credencial de recuperação (admin + hash) são avaliadas lá. + return self.settings.verify_credentials(user, pwd) except Exception: return False @@ -52,7 +83,7 @@ def require_auth(self) -> bool: self.send_header("WWW-Authenticate", 'Basic realm="OminiRTKSync Dashboard"') self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() - self.wfile.write(b"Authentication required. Default credentials: admin / pathbit") + self.write_body(b"Autenticacao requerida. Credenciais padrao: admin / pathbit") return False def do_GET(self): @@ -63,11 +94,12 @@ def do_GET(self): if not self.require_auth(): return - if self.path in ("/", "/index.html"): - self.serve_html() - elif self.path == "/api/status": + route = urlparse(self.path) + if route.path in ("/", "/index.html"): + self.serve_dashboard(query=parse_qs(route.query)) + elif route.path == "/api/status": self.serve_status() - elif self.path == "/api/cron-status": + elif route.path == "/api/cron-status": self.serve_cron_status() else: self.send_error(HTTPStatus.NOT_FOUND) @@ -79,44 +111,74 @@ def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw_body = self.rfile.read(length) if length > 0 else b"{}" - if self.path == "/api/sync": + route = urlparse(self.path).path + + # Acoes do dashboard: executam e redirecionam de volta para a pagina + # renderizada (POST-Redirect-GET), sem JSON no navegador. + if route.startswith("/acoes/"): + self.handle_dashboard_action(route, raw_body) + return + + if route == "/api/sync": self.handle_sync() - elif self.path == "/api/test-gateway": + elif route == "/api/test-gateway": self.handle_test_gateway() - elif self.path == "/api/change-password": + elif route == "/api/change-password": self.handle_change_password(raw_body) - elif self.path == "/api/cron-run": + elif route == "/api/cron-run": self.handle_cron_run() else: self.send_error(HTTPStatus.NOT_FOUND) + def probe_gateway(self) -> bool: + """Sonda o gateway com cache: o resultado vale por GATEWAY_PROBE_TTL_SECONDS.""" + if not self.omniroute_url: + return True + + now = time.time() + with _gateway_probe_lock: + cached_at, cached_ok = _gateway_probe_cache.get(self.omniroute_url, (0.0, None)) + if cached_ok is not None and (now - cached_at) < GATEWAY_PROBE_TTL_SECONDS: + return cached_ok + + try: + req = urllib.request.Request( + self.omniroute_url, + headers={"User-Agent": "OminiRTKSync-Healthcheck/1.0"}, + ) + with urllib.request.urlopen(req, timeout=3.0) as resp: + gateway_ok = resp.status < 500 + except urllib.error.HTTPError as e: + gateway_ok = e.code < 500 + except Exception: + gateway_ok = False + + with _gateway_probe_lock: + _gateway_probe_cache[self.omniroute_url] = (time.time(), gateway_ok) + return gateway_ok + + def write_body(self, payload: bytes) -> None: + """Escreve o corpo tolerando o cliente ter fechado a conexão antes da leitura.""" + try: + self.wfile.write(payload) + except CLIENT_DISCONNECT_ERRORS: + self.close_connection = True + def serve_healthz(self): db_ok = bool(self.db_path and os.path.exists(self.db_path)) - router_ok = True - if self.omniroute_url: - try: - req = urllib.request.Request( - self.omniroute_url, - headers={"User-Agent": "OminiRTKSync-Healthcheck/1.0"}, - ) - with urllib.request.urlopen(req, timeout=3.0) as resp: - router_ok = resp.status < 500 - except urllib.error.HTTPError as e: - router_ok = e.code < 500 - except Exception: - router_ok = False + gateway_ok = self.probe_gateway() - if db_ok and router_ok: - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/plain") - self.end_headers() - self.wfile.write(b"OK") + if db_ok and gateway_ok: + payload, status = b"OK", HTTPStatus.OK else: reason = "DATABASE_NOT_READY" if not db_ok else "OMNIROUTE_SERVICE_UNREACHABLE" - self.send_response(HTTPStatus.SERVICE_UNAVAILABLE) - self.send_header("Content-Type", "text/plain") - self.end_headers() - self.wfile.write(reason.encode("utf-8")) + payload, status = reason.encode("utf-8"), HTTPStatus.SERVICE_UNAVAILABLE + + self.send_response(status) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.write_body(payload) def serve_status(self): conns = [] @@ -150,7 +212,7 @@ def serve_status(self): self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def serve_cron_status(self): cron_info = self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False} @@ -159,7 +221,7 @@ def serve_cron_status(self): self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_test_gateway(self): start_t = time.time() @@ -207,7 +269,7 @@ def handle_test_gateway(self): "dbPath": self.db_path, "connectionsCount": conns_count, "combosCount": combos_count, - "message": "OmniRoute gateway and storage.sqlite database 100% operational!" if (gateway_ok and db_exists) else "Failed to connect to OmniRoute or database unavailable", + "message": "Gateway OmniRoute e banco storage.sqlite 100% operacionais!" if (gateway_ok and db_exists) else "Falha ao conectar ao OmniRoute ou banco indisponivel", } body = json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8") @@ -215,7 +277,7 @@ def handle_test_gateway(self): self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_change_password(self, raw_body: bytes): try: @@ -224,12 +286,25 @@ def handle_change_password(self, raw_body: bytes): new_pass = str(data.get("newPassword") or "").strip() if not new_pass or len(new_pass) < 4: - body = json.dumps({"success": False, "error": "Password must be at least 4 characters long."}).encode("utf-8") + body = json.dumps({"success": False, "error": "A senha deve conter ao menos 4 caracteres."}).encode("utf-8") self.send_response(HTTPStatus.BAD_REQUEST) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) + return + + if self.settings and getattr(self.settings, "dashboard_auth_from_env", False): + body = json.dumps({ + "success": False, + "error": "Credenciais definidas por variável de ambiente (DASHBOARD_USER/DASHBOARD_PASSWORD). " + "Altere-as no ambiente e reinicie o serviço.", + }).encode("utf-8") + self.send_response(HTTPStatus.CONFLICT) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.write_body(body) return if self.settings: @@ -237,24 +312,24 @@ def handle_change_password(self, raw_body: bytes): if ok: body = json.dumps({ "success": True, - "message": "Credentials updated successfully!", + "message": "Credenciais atualizadas com sucesso!", "newUser": new_user, }).encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) return - self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to save credentials") + self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "Falha ao salvar credenciais") except Exception as e: body = json.dumps({"success": False, "error": str(e)}).encode("utf-8") self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_sync(self): if self.sync_callback: @@ -265,14 +340,14 @@ def handle_sync(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) except Exception as e: body = json.dumps({"success": False, "error": str(e)}).encode("utf-8") self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) else: self.send_error(HTTPStatus.SERVICE_UNAVAILABLE) @@ -285,7 +360,7 @@ def handle_cron_run(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) return except Exception as e: err = json.dumps({"success": False, "error": str(e)}).encode("utf-8") @@ -293,568 +368,181 @@ def handle_cron_run(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(err))) self.end_headers() - self.wfile.write(err) + self.write_body(err) return self.handle_sync() - def serve_html(self): - html = """ - - - - - OminiRTKSync · OmniRoute Universal Token & Connection Synchronizer - - - -
    - -
    -
    - ⚠️ Security Notice: You are using the factory default credentials (admin / pathbit). It is strongly recommended to change your password to secure the dashboard. -
    - -
    - -
    -
    -

    ⚡ OminiRTKSync

    -

    OminiRoute Universal Token & Connection Synchronizer (OmniRoute)

    -
    -
    - - -
    -
    - -
    -
    -
    Total Connections
    -
    -
    -
    -
    -
    Active OAuth Accounts
    -
    -
    -
    -
    -
    API Key Providers
    -
    -
    -
    -
    -
    Registered Combos
    -
    -
    -
    -
    - -
    -
    -
    - 🔌 OmniRoute Gateway Connection - -
    -

    Validates HTTP response, latency, and access to the storage.sqlite database.

    -
    -
    OmniRoute URL:-
    -
    Gateway Status:Awaiting test...
    -
    HTTP Latency:-
    -
    SQLite Database:-
    -
    Diagnostic:Click 'Test Connection'
    -
    -
    - -
    -
    - ⏰ Cron Scheduler (Continuous Refresh) - -
    -
    -
    - Active - (every 300s) -
    - Total cycles: 0 -
    -
    -
    Last Run:-
    -
    Next Run:-
    -
    Tokens Refreshed:0
    -
    Last Result:-
    -
    -
    -
    - -
    🔌 Monitored Connections in OmniRoute
    -
    - - - - - - - - - - - - - -
    ProviderNameTypeStatusRemaining Validity
    Loading connections from storage.sqlite...
    -
    - -
    📋 Cron Cycle History
    -
    - - - - - - - - - - - - - - -
    Date & Time (UTC)TriggerDurationAccounts InspectedOAuth RefreshedStatus
    No historical cycles recorded yet.
    -
    - - -
    - - - - - -""" - content = html.encode("utf-8") + + def serve_dashboard(self, query: Optional[Dict[str, list]] = None): + """Renderiza a pagina inteira no servidor, com os dados ja embutidos.""" + query = query or {} + state = self.collect_dashboard_state() + + flash = None + aviso = (query.get("aviso") or [""])[0] + if aviso: + flash = {"message": aviso, "tone": (query.get("tom") or ["info"])[0]} + + current_user, is_default, auth_from_env, refresh_margin = "admin", False, False, 900 + if self.settings: + current_user, _ = self.settings.get_auth_credentials() + is_default = self.settings.is_default_password() + auth_from_env = getattr(self.settings, "dashboard_auth_from_env", False) + refresh_margin = self.settings.refresh_margin + + content = render_dashboard( + connections=state["connections"], + combos=state["combos"], + cron=state["cron"], + gateway=state["gateway"], + db_path=self.db_path, + router_url=self.omniroute_url, + current_user=current_user, + is_default_password=is_default, + refresh_margin=refresh_margin, + auth_from_env=auth_from_env, + flash=flash, + lang=self.resolve_language(), + ).encode("utf-8") + self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") + # A pagina carrega dados vivos: nunca pode vir do cache do navegador. + self.send_header("Cache-Control", "no-store, must-revalidate") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") self.send_header("Content-Length", str(len(content))) self.end_headers() - self.wfile.write(content) + self.write_body(content) + + def redirect_to_dashboard(self, tone: str, message: str) -> None: + """Redireciona para a pagina com uma mensagem de resultado.""" + query = urlencode({"aviso": message, "tom": tone}) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/?{query}") + self.send_header("Content-Length", "0") + self.end_headers() + + def handle_dashboard_action(self, route: str, raw_body: bytes) -> None: + """Executa uma acao do painel e devolve o usuario para a pagina renderizada.""" + if route == "/acoes/idioma": + fields = parse_qs(raw_body.decode("utf-8", errors="replace")) + chosen = normalize_language((fields.get("lang", [""])[0] or "").strip()) + set_preference(self.prefs_path(), "language", chosen) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if route == "/acoes/sincronizar": + if not self.sync_callback: + self.redirect_to_dashboard("warning", "Sincronizacao manual indisponivel nesta instancia.") + return + try: + res = self.sync_callback() or {} + self.redirect_to_dashboard( + "success", + f"Sincronizacao concluida: {res.get('total_connections', res.get('total', 0))} " + f"conexoes inspecionadas, {res.get('refreshed', 0)} renovadas.", + ) + except Exception as e: + self.redirect_to_dashboard("danger", f"Falha na sincronizacao: {e}") + return + + if route == "/acoes/cron": + if not self.cron_scheduler: + self.redirect_to_dashboard("warning", "Agendador nao esta ativo nesta instancia.") + return + try: + entry = self.cron_scheduler.trigger_now() or {} + self.redirect_to_dashboard( + "success", + f"Ciclo executado em {entry.get('durationMs', 0)}ms: " + f"{entry.get('totalInspected', 0)} avaliadas, {entry.get('refreshedCount', 0)} renovadas.", + ) + except Exception as e: + self.redirect_to_dashboard("danger", f"Falha ao executar o ciclo: {e}") + return + + if route == "/acoes/testar-gateway": + # Invalida o cache para forcar uma sondagem real nesta acao explicita. + with _gateway_probe_lock: + _gateway_probe_cache.pop(self.omniroute_url, None) + online = self.probe_gateway() + self.redirect_to_dashboard( + "success" if online else "danger", + "Gateway respondeu normalmente." if online else "Gateway nao respondeu.", + ) + return + + if route == "/acoes/credenciais": + fields = parse_qs(raw_body.decode("utf-8", errors="replace")) + new_user = (fields.get("user", [""])[0] or "").strip() + new_pass = (fields.get("password", [""])[0] or "").strip() + + if len(new_pass) < 4: + self.redirect_to_dashboard("danger", "A senha deve conter ao menos 4 caracteres.") + return + if self.settings and getattr(self.settings, "dashboard_auth_from_env", False): + self.redirect_to_dashboard( + "warning", + "Credenciais definidas por variavel de ambiente. Altere-as no ambiente e reinicie.", + ) + return + if self.settings and self.settings.update_auth_credentials(new_user, new_pass): + self.redirect_to_dashboard("success", "Credenciais atualizadas. Autentique-se novamente.") + return + self.redirect_to_dashboard("danger", "Nao foi possivel salvar as credenciais.") + return + + self.send_error(HTTPStatus.NOT_FOUND, "Acao nao encontrada") + def start_omini_web( @@ -865,15 +553,14 @@ def start_omini_web( sync_callback: Optional[Callable[[], Dict[str, Any]]] = None, settings: Optional[Settings] = None, cron_scheduler: Optional[Any] = None, -) -> HTTPServer: +) -> ThreadingHTTPServer: OminiDashboardHandler.db_path = db_path OminiDashboardHandler.omniroute_url = omniroute_url OminiDashboardHandler.sync_callback = sync_callback OminiDashboardHandler.settings = settings OminiDashboardHandler.cron_scheduler = cron_scheduler - server = HTTPServer((host, port), OminiDashboardHandler) + server = QuietThreadingHTTPServer((host, port), OminiDashboardHandler) t = threading.Thread(target=server.serve_forever, daemon=True) t.start() return server - diff --git a/tests/__init__.py b/tests/__init__.py index eaecf5a..fc81913 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Unit test suite for OminiRTKSync.""" +"""Suíte de testes unitários do OminiRTKSync.""" diff --git a/tests/test_auth_recovery.py b/tests/test_auth_recovery.py new file mode 100644 index 0000000..733a1b1 --- /dev/null +++ b/tests/test_auth_recovery.py @@ -0,0 +1,189 @@ +"""Testes da regra de autenticação do dashboard, incluindo a credencial de recuperação.""" + +import json +import os +import stat +import tempfile +import unittest +from unittest import mock + +from omini_rtksync.auth import ( + constant_time_equals, + derive_recovery_hash, + ensure_recovery_hash, + read_stored_credentials, + resolve_recovery_hash, + verify_credentials, +) +from omini_rtksync.config import Settings + +FACTORY = {"factory_user": "admin", "factory_password": "pathbit"} + + +class TestVerifyCredentials(unittest.TestCase): + """A ordem de validação: salvas -> padrão de fábrica (se nada salvo) -> recuperação.""" + + def test_factory_credentials_work_while_nothing_is_stored(self): + self.assertTrue(verify_credentials("admin", "pathbit", stored=None, **FACTORY)) + + def test_wrong_factory_password_is_rejected(self): + self.assertFalse(verify_credentials("admin", "errada", stored=None, **FACTORY)) + + def test_stored_credentials_replace_the_factory_ones(self): + stored = ("operador", "senha-nova") + self.assertTrue(verify_credentials("operador", "senha-nova", stored=stored, **FACTORY)) + # Depois da troca, a senha de fábrica nao vale mais. + self.assertFalse(verify_credentials("admin", "pathbit", stored=stored, **FACTORY)) + + def test_recovery_hash_always_works_for_admin(self): + stored = ("operador", "senha-esquecida") + recovery = derive_recovery_hash("segredo") + self.assertTrue( + verify_credentials("admin", recovery, stored=stored, recovery_hash=recovery, **FACTORY) + ) + + def test_recovery_hash_works_even_before_any_password_change(self): + recovery = derive_recovery_hash("segredo") + self.assertTrue( + verify_credentials("admin", recovery, stored=None, recovery_hash=recovery, **FACTORY) + ) + + def test_recovery_hash_only_works_for_the_admin_user(self): + recovery = derive_recovery_hash("segredo") + self.assertFalse( + verify_credentials( + "operador", recovery, stored=None, recovery_hash=recovery, **FACTORY + ) + ) + + def test_everything_else_is_invalid(self): + stored = ("operador", "senha-nova") + recovery = derive_recovery_hash("segredo") + for user, password in [ + ("admin", "pathbit"), + ("admin", "chute"), + ("operador", "chute"), + ("outro", "senha-nova"), + ("", ""), + ("admin", ""), + ("", "senha-nova"), + ]: + with self.subTest(user=user, password=password): + self.assertFalse( + verify_credentials( + user, password, stored=stored, recovery_hash=recovery, **FACTORY + ) + ) + + def test_empty_recovery_hash_never_grants_access(self): + # Sem hash configurado, uma senha vazia nao pode virar chave mestra. + self.assertFalse( + verify_credentials("admin", "", stored=None, recovery_hash="", **FACTORY) + ) + + def test_constant_time_equals_handles_none(self): + self.assertTrue(constant_time_equals("a", "a")) + self.assertFalse(constant_time_equals("a", None)) + self.assertTrue(constant_time_equals(None, None)) + + +class TestRecoveryHashStorage(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.recovery_file = os.path.join(self.tmp_dir.name, ".dashboard_recovery") + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_env_hash_wins(self): + with mock.patch.dict(os.environ, {"DASHBOARD_RECOVERY_HASH": "do-ambiente"}, clear=True): + self.assertEqual(resolve_recovery_hash(self.recovery_file), "do-ambiente") + + def test_generated_hash_is_persisted_and_reused(self): + with mock.patch.dict(os.environ, {}, clear=True): + first, generated = ensure_recovery_hash(self.recovery_file) + self.assertTrue(generated) + self.assertTrue(first) + + second, generated_again = ensure_recovery_hash(self.recovery_file) + self.assertFalse(generated_again) + self.assertEqual(second, first) + + def test_generated_hash_file_is_owner_only(self): + with mock.patch.dict(os.environ, {}, clear=True): + ensure_recovery_hash(self.recovery_file) + mode = stat.S_IMODE(os.stat(self.recovery_file).st_mode) + self.assertEqual(mode, 0o600) + + def test_unwritable_path_still_returns_a_hash(self): + with mock.patch.dict(os.environ, {}, clear=True): + value, generated = ensure_recovery_hash("/proc/nao-pode/recovery") + self.assertTrue(value) + self.assertTrue(generated) + + def test_read_stored_credentials_handles_missing_and_corrupt_files(self): + self.assertIsNone(read_stored_credentials("")) + self.assertIsNone(read_stored_credentials(os.path.join(self.tmp_dir.name, "nao-existe"))) + + corrupt = os.path.join(self.tmp_dir.name, "corrupto.json") + with open(corrupt, "w", encoding="utf-8") as f: + f.write("{nao e json") + self.assertIsNone(read_stored_credentials(corrupt)) + + incomplete = os.path.join(self.tmp_dir.name, "incompleto.json") + with open(incomplete, "w", encoding="utf-8") as f: + json.dump({"user": "só-usuario"}, f) + self.assertIsNone(read_stored_credentials(incomplete)) + + +class TestSettingsAuthIntegration(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp_dir.name, "data.sqlite") + open(self.db_path, "w").close() + + def tearDown(self): + self.tmp_dir.cleanup() + + def _settings(self, **env): + base = {"DB_PATH": self.db_path, "DATA_DIR": self.tmp_dir.name} + base.update(env) + with mock.patch.dict(os.environ, base, clear=True): + return Settings.from_env(env_file=""), dict(base) + + def test_full_lifecycle_from_factory_to_change_to_recovery(self): + settings, base = self._settings() + with mock.patch.dict(os.environ, base, clear=True): + # 1. Primeiro acesso: credenciais de fábrica. + self.assertTrue(settings.verify_credentials("admin", "pathbit")) + + # 2. Operador troca a senha pela tela. + self.assertTrue(settings.update_auth_credentials("operador", "minha-senha")) + self.assertTrue(settings.verify_credentials("operador", "minha-senha")) + self.assertFalse(settings.verify_credentials("admin", "pathbit")) + + # 3. Esqueceu a senha: entra com admin + hash de recuperação. + recovery, _ = settings.ensure_recovery_hash() + self.assertTrue(settings.verify_credentials("admin", recovery)) + + # 4. Qualquer outra combinação segue inválida. + self.assertFalse(settings.verify_credentials("admin", "chute")) + self.assertFalse(settings.verify_credentials("operador", recovery)) + + def test_recovery_hash_can_be_pinned_by_environment(self): + settings, base = self._settings(DASHBOARD_RECOVERY_HASH="hash-fixo-do-container") + with mock.patch.dict(os.environ, base, clear=True): + settings.update_auth_credentials("operador", "minha-senha") + self.assertTrue(settings.verify_credentials("admin", "hash-fixo-do-container")) + + def test_env_authoritative_mode_ignores_the_saved_file(self): + settings, base = self._settings(DASHBOARD_PASSWORD="do-ambiente") + with mock.patch.dict(os.environ, base, clear=True): + # A tela nao consegue sobrescrever o ambiente. + self.assertFalse(settings.update_auth_credentials("da-tela", "da-tela")) + self.assertTrue(settings.verify_credentials("admin", "do-ambiente")) + self.assertFalse(settings.verify_credentials("da-tela", "da-tela")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index dc35d9a..f4056f8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -"""Unit tests for OminiRTKSync CLI.""" +"""Testes unitários da CLI do OminiRTKSync.""" import os import sqlite3 @@ -51,7 +51,7 @@ def test_sync_engine_once(self): def test_print_status(self): settings = Settings(db_path=self.db_path, enable_web=False) - # Should execute without raising exceptions + # Deve executar sem levantar exceção print_status(settings) def test_cli_main_status(self): diff --git a/tests/test_config_env.py b/tests/test_config_env.py index f2db2e6..163f380 100644 --- a/tests/test_config_env.py +++ b/tests/test_config_env.py @@ -1,8 +1,10 @@ -"""Unit tests for environment variable loading and .env file handling.""" +"""Testes de carregamento de .env e da configuração 100% por variável de ambiente.""" import os import tempfile import unittest +from unittest import mock + from omini_rtksync.config import load_dotenv, Settings @@ -16,7 +18,7 @@ def tearDown(self): def test_load_dotenv_parses_key_values_and_quotes(self): with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8") as f: - f.write("# Comment\n") + f.write("# Comentario\n") f.write("TEST_ENV_VAR1=valor_um\n") f.write('TEST_ENV_VAR2="valor com aspas"\n') f.write("TEST_ENV_VAR3='valor com aspas simples'\n") @@ -30,7 +32,7 @@ def test_load_dotenv_parses_key_values_and_quotes(self): self.assertEqual(os.environ.get("TEST_ENV_VAR1"), "valor_um") self.assertEqual(os.environ.get("TEST_ENV_VAR2"), "valor com aspas") self.assertEqual(os.environ.get("TEST_ENV_VAR3"), "valor com aspas simples") - # Should not overwrite existing environment variables + # Nao deve sobrescrever variaveis ja existentes self.assertEqual(os.environ.get("TEST_EXISTING"), "valor_original") finally: if os.path.exists(temp_path): @@ -57,5 +59,100 @@ def test_settings_from_env_loads_custom_env_file(self): os.remove(temp_path) +class TestSettingsFromEnv(unittest.TestCase): + """Cobre o contrato: toda configuração é alcançável sem abrir o dashboard.""" + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp_dir.name, "storage.sqlite") + open(self.db_path, "w").close() + + def tearDown(self): + self.tmp_dir.cleanup() + + def _env(self, **overrides): + """Ambiente limpo com apenas as variáveis informadas.""" + base = {"DB_PATH": self.db_path, "HOST_HOME": self.tmp_dir.name} + base.update(overrides) + return mock.patch.dict(os.environ, base, clear=True) + + @staticmethod + def _settings(): + # env_file inexistente: isola o teste de qualquer .env presente no diretório. + return Settings.from_env(env_file="") + + def test_defaults_without_any_variable(self): + with self._env(): + s = self._settings() + self.assertEqual(s.sync_interval, 300) + self.assertEqual(s.cron_interval, 300) + self.assertTrue(s.cron_enabled) + self.assertEqual(s.web_port, 9090) + self.assertTrue(s.enable_web) + self.assertFalse(s.dashboard_auth_from_env) + + def test_every_knob_is_reachable_from_the_environment(self): + with self._env( + OMNIROUTE_URL="http://gateway:20128", + SYNC_INTERVAL="60", + REFRESH_MARGIN="120", + ENABLE_WEB_DASHBOARD="0", + WEB_HOST="127.0.0.1", + WEB_PORT="9999", + CRON_INTERVAL="45", + CRON_ENABLED="0", + DASHBOARD_USER="operador", + DASHBOARD_PASSWORD="segredo-forte", + ): + s = self._settings() + + self.assertEqual(s.omniroute_url, "http://gateway:20128") + self.assertEqual(s.sync_interval, 60) + self.assertEqual(s.refresh_margin, 120) + self.assertFalse(s.enable_web) + self.assertEqual(s.web_host, "127.0.0.1") + self.assertEqual(s.web_port, 9999) + self.assertEqual(s.cron_interval, 45) + self.assertFalse(s.cron_enabled) + self.assertEqual(s.get_auth_credentials(), ("operador", "segredo-forte")) + + def test_cron_interval_defaults_to_sync_interval(self): + with self._env(SYNC_INTERVAL="90"): + s = self._settings() + self.assertEqual(s.cron_interval, 90) + + def test_env_credentials_override_the_saved_file(self): + """Sem isto, uma única troca de senha pela tela tornaria o ambiente inerte.""" + with self._env(DASHBOARD_USER="operador", DASHBOARD_PASSWORD="do-ambiente"): + s = self._settings() + # Simula um arquivo gravado anteriormente pela tela. + with open(s.get_auth_file_path(), "w", encoding="utf-8") as f: + f.write('{"user": "da-tela", "password": "da-tela"}') + + self.assertTrue(s.dashboard_auth_from_env) + self.assertEqual(s.get_auth_credentials(), ("operador", "do-ambiente")) + + def test_screen_cannot_overwrite_env_credentials(self): + with self._env(DASHBOARD_PASSWORD="do-ambiente"): + s = self._settings() + self.assertFalse(s.update_auth_credentials("novo", "nova-senha")) + self.assertEqual(s.get_auth_credentials(), ("admin", "do-ambiente")) + + def test_saved_file_still_wins_when_env_is_absent(self): + """Sem variáveis definidas, a tela continua sendo a fonte de verdade.""" + with self._env(): + s = self._settings() + self.assertTrue(s.update_auth_credentials("da-tela", "senha-da-tela")) + self.assertEqual(s.get_auth_credentials(), ("da-tela", "senha-da-tela")) + + def test_default_password_detection(self): + with self._env(): + s = self._settings() + self.assertTrue(s.is_default_password()) + with self._env(DASHBOARD_PASSWORD="outra-coisa"): + s = self._settings() + self.assertFalse(s.is_default_password()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cron.py b/tests/test_cron.py index a7b31e2..6de212e 100644 --- a/tests/test_cron.py +++ b/tests/test_cron.py @@ -1,4 +1,4 @@ -"""Unit tests for OminiRTKSync CronScheduler.""" +"""Testes unitários para o CronScheduler do OminiRTKSync.""" import time import unittest diff --git a/tests/test_discovery.py b/tests/test_discovery.py index df34169..12ed4fa 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,4 +1,4 @@ -"""Unit tests for discovery engine and multiple providers in OminiRTKSync.""" +"""Testes unitários para motor de descoberta e múltiplos provedores do OminiRTKSync.""" import json import os diff --git a/tests/test_gateway_diag.py b/tests/test_gateway_diag.py index 48060d4..4e4a8fe 100644 --- a/tests/test_gateway_diag.py +++ b/tests/test_gateway_diag.py @@ -1,4 +1,4 @@ -"""Diagnostic tests for OmniRoute gateway and test endpoint.""" +"""Testes de diagnóstico do gateway OmniRoute e endpoint de teste.""" import base64 import json diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..4afe681 --- /dev/null +++ b/tests/test_logs.py @@ -0,0 +1,140 @@ +"""Testes do log persistente em arquivo, rotação e expurgo por idade.""" + +import os +import tempfile +import time +import unittest +from unittest import mock + +from omini_rtksync import logs + + +class TestLogRetention(unittest.TestCase): + def setUp(self): + logs.reset_logging() + self.tmp_dir = tempfile.TemporaryDirectory() + self.log_dir = os.path.join(self.tmp_dir.name, "logs") + os.makedirs(self.log_dir, exist_ok=True) + + def tearDown(self): + logs.reset_logging() + self.tmp_dir.cleanup() + + def _touch(self, name: str, age_days: float): + path = os.path.join(self.log_dir, name) + with open(path, "w", encoding="utf-8") as f: + f.write("linha de log\n") + past = time.time() - (age_days * 86400) + os.utime(path, (past, past)) + return path + + def test_default_retention_is_thirty_days(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(logs.get_retention_days(), 30) + + def test_retention_is_configurable(self): + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "90"}, clear=True): + self.assertEqual(logs.get_retention_days(), 90) + + def test_invalid_retention_falls_back_to_default(self): + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "nao-e-numero"}, clear=True): + self.assertEqual(logs.get_retention_days(), 30) + + def test_retention_has_a_floor_of_one_day(self): + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "0"}, clear=True): + self.assertEqual(logs.get_retention_days(), 1) + + def test_purge_removes_only_files_older_than_retention(self): + recent = self._touch(f"{logs.LOG_FILE_NAME}.2026-09-10", age_days=2) + old = self._touch(f"{logs.LOG_FILE_NAME}.2026-07-01", age_days=45) + active = self._touch(logs.LOG_FILE_NAME, age_days=99) + unrelated = self._touch("outro-servico.log.2026-01-01", age_days=99) + + with mock.patch.dict(os.environ, {}, clear=True): + removed = logs.purge_expired_logs(self.log_dir) + + self.assertEqual(removed, 1) + self.assertFalse(os.path.exists(old)) + self.assertTrue(os.path.exists(recent)) + # O arquivo ativo nunca e apagado, mesmo que a data de modificacao seja antiga. + self.assertTrue(os.path.exists(active)) + # Arquivos de outros servicos no mesmo diretorio ficam intactos. + self.assertTrue(os.path.exists(unrelated)) + + def test_purge_respects_a_longer_configured_retention(self): + old = self._touch(f"{logs.LOG_FILE_NAME}.2026-07-01", age_days=45) + + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "60"}, clear=True): + removed = logs.purge_expired_logs(self.log_dir) + + self.assertEqual(removed, 0) + self.assertTrue(os.path.exists(old)) + + def test_purge_on_missing_directory_is_a_noop(self): + self.assertEqual(logs.purge_expired_logs(os.path.join(self.tmp_dir.name, "nao-existe")), 0) + + +class TestLogSetup(unittest.TestCase): + def setUp(self): + logs.reset_logging() + self.tmp_dir = tempfile.TemporaryDirectory() + + def tearDown(self): + logs.reset_logging() + self.tmp_dir.cleanup() + + def test_events_are_written_to_the_log_file(self): + log_dir = os.path.join(self.tmp_dir.name, "logs") + with mock.patch.dict(os.environ, {"LOG_DIR": log_dir, "LOG_TO_STDOUT": "0"}, clear=True): + logger = logs.setup_logging() + logger.info("[SYNC] token renovado") + for handler in logger.handlers: + handler.flush() + + log_file = os.path.join(log_dir, logs.LOG_FILE_NAME) + self.assertTrue(os.path.exists(log_file)) + with open(log_file, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("[SYNC] token renovado", content) + + def test_stdout_mirror_can_be_disabled(self): + log_dir = os.path.join(self.tmp_dir.name, "logs") + with mock.patch.dict(os.environ, {"LOG_DIR": log_dir, "LOG_TO_STDOUT": "0"}, clear=True): + logger = logs.setup_logging() + stream_handlers = [ + h for h in logger.handlers if type(h).__name__ == "StreamHandler" + ] + self.assertEqual(stream_handlers, []) + + def test_stdout_mirror_is_on_by_default(self): + log_dir = os.path.join(self.tmp_dir.name, "logs") + with mock.patch.dict(os.environ, {"LOG_DIR": log_dir}, clear=True): + logger = logs.setup_logging() + stream_handlers = [ + h for h in logger.handlers if type(h).__name__ == "StreamHandler" + ] + self.assertEqual(len(stream_handlers), 1) + + def test_unwritable_directory_does_not_break_startup(self): + # Um caminho impossivel de criar nao pode derrubar o sincronizador. + impossible = "/proc/nao-pode-criar/logs" + with mock.patch.dict(os.environ, {"LOG_DIR": impossible, "LOG_TO_STDOUT": "0"}, clear=True): + logger = logs.setup_logging() + self.assertIsNotNone(logger) + + def test_log_dir_defaults_next_to_the_database(self): + db_path = os.path.join(self.tmp_dir.name, "data.sqlite") + open(db_path, "w").close() + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual( + logs.resolve_log_dir(db_path), os.path.join(self.tmp_dir.name, "logs") + ) + + def test_log_dir_env_wins_over_the_database_path(self): + with mock.patch.dict(os.environ, {"LOG_DIR": "/var/log/custom"}, clear=True): + self.assertEqual(logs.resolve_log_dir("/app/data/db/data.sqlite"), "/var/log/custom") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_normalizer.py b/tests/test_normalizer.py index 8003919..9ff66d4 100644 --- a/tests/test_normalizer.py +++ b/tests/test_normalizer.py @@ -1,4 +1,4 @@ -"""Unit tests for date normalization in OmniRoute.""" +"""Testes unitários de normalização de datas para OmniRoute.""" import unittest from omini_rtksync.normalizer import parse_expiry_to_ms diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py index 1d1bde3..99e5d53 100644 --- a/tests/test_web_auth.py +++ b/tests/test_web_auth.py @@ -1,4 +1,4 @@ -"""Tests for HTTP Basic Auth and protected routes in OminiRTKSync.""" +"""Testes de autenticação HTTP Basic Auth e rotas protegidas no OminiRTKSync.""" import base64 import json @@ -52,7 +52,7 @@ def test_dashboard_rejects_without_auth(self): url = f"http://127.0.0.1:{self.settings.web_port}/" try: urllib.request.urlopen(url, timeout=3.0) - self.fail("Should return HTTP 401") + self.fail("Deveria retornar HTTP 401") except urllib.error.HTTPError as e: self.assertEqual(e.code, 401) self.assertIn("Basic", e.headers.get("WWW-Authenticate", "")) diff --git a/tests/test_web_render.py b/tests/test_web_render.py new file mode 100644 index 0000000..69e65e5 --- /dev/null +++ b/tests/test_web_render.py @@ -0,0 +1,303 @@ +"""Testes da renderização server-side do dashboard.""" + +import base64 +import json +import os +import re +import sqlite3 +import tempfile +import time +import unittest +from datetime import datetime, timezone +import urllib.error +import urllib.request + +from omini_rtksync.config import Settings +from omini_rtksync.models import ConnectionRecord +from omini_rtksync import render +from omini_rtksync import web as web_server + +# Faixas de emoji que não podem aparecer na interface (o padrão é fonte de ícones). +EMOJI_PATTERN = re.compile( + "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U00002B00-\U00002BFF\U0001F1E6-\U0001F1FF]" +) + + +def make_conn(provider: str, name: str, data: dict) -> ConnectionRecord: + return ConnectionRecord(id=f"id-{provider}", provider=provider, name=name, data=data) + + +class TestRenderHelpers(unittest.TestCase): + def test_duration_formatting(self): + self.assertEqual(render.format_duration(None), "Unlimited / N/A") + self.assertEqual(render.format_duration(None, "pt"), "Ilimitado / N/A") + self.assertEqual(render.format_duration(0), "Expired") + self.assertEqual(render.format_duration(-5, "es"), "Expirado") + self.assertEqual(render.format_duration(45), "45s") + self.assertEqual(render.format_duration(1440), "24 min") + self.assertEqual(render.format_duration(3660), "1h 01min") + self.assertEqual(render.format_duration(90000), "1d 1h") + + def test_html_is_escaped(self): + self.assertEqual(render.esc(""), "<script>alert(1)</script>") + + def test_refresh_reason_explains_why_nothing_was_renewed(self): + """O caso real: 24 min restantes com margem de 15 min não renova — e isso precisa ficar visível.""" + now_ms = int(time.time() * 1000) + conn = make_conn("antigravity", "Google Antigravity Pro", { + "accessToken": "tok", + "refreshToken": "ref", + "expiresAt": now_ms + (24 * 60 * 1000), + }) + self.assertIn("Outside the 15 min margin", render.render_refresh_reason(conn, 900)) + reason_pt = render.render_refresh_reason(conn, refresh_margin=900, lang="pt") + self.assertIn("Fora da margem de 15 min", reason_pt) + self.assertIn("renovação prevista", reason_pt) + + def test_refresh_reason_inside_margin(self): + now_ms = int(time.time() * 1000) + conn = make_conn("antigravity", "AG", { + "accessToken": "tok", "refreshToken": "ref", + "expiresAt": now_ms + (5 * 60 * 1000), + }) + self.assertIn("Within the 15 min margin", render.render_refresh_reason(conn, 900)) + self.assertIn("Dentro da margem", render.render_refresh_reason(conn, 900, "pt")) + + def test_refresh_reason_for_expired_and_apikey(self): + now_ms = int(time.time() * 1000) + expired = make_conn("antigravity", "AG", { + "accessToken": "t", "refreshToken": "r", "expiresAt": now_ms - 1000, + }) + self.assertIn("expired", render.render_refresh_reason(expired, 900).lower()) + + apikey = make_conn("groq", "Groq", {"apiKey": "gsk-xxx"}) + self.assertIn("never expires", render.render_refresh_reason(apikey, 900)) + self.assertIn("não expira", render.render_refresh_reason(apikey, 900, "pt")) + + def test_local_instance_reason_reports_models(self): + """O Ollama local precisa aparecer como local, com os modelos que serve.""" + local = make_conn("openai-compatible-chat-ollama-local", "Ollama Local Host", { + "apiKey": "fachada", + "baseUrl": "http://localhost:11434/v1", + "discoveredModels": ["llama3.2:3b", "qwen2.5-coder:7b"], + }) + self.assertTrue(local.is_local) + self.assertIn("2 model(s)", render.render_refresh_reason(local, 900)) + + def test_unreachable_local_instance_is_reported(self): + local = make_conn("ollama-local", "Ollama Local", { + "apiKey": "k", "baseUrl": "http://localhost:11434/v1", + }) + self.assertIn("did not answer", render.render_refresh_reason(local, 900)) + + +class TestDashboardMarkup(unittest.TestCase): + def _page(self, **overrides): + now_ms = int(time.time() * 1000) + base = dict( + connections=[ + make_conn("antigravity", "Google Antigravity Pro", { + "accessToken": "tok", "refreshToken": "ref", + "expiresAt": now_ms + (24 * 60 * 1000), + }), + make_conn("groq", "Groq Cloud PathBit", {"apiKey": "gsk-xxx"}), + ], + combos=[{"name": "arsenal-supremo", "kind": "fallback", "models": ["a", "b", "c"]}], + cron={"active": True, "intervalSeconds": 300, "totalRuns": 4, "totalRenewals": 0, + "lastRunAt": "2026-09-12T13:46:53Z", "nextRunAt": "2026-09-12T13:51:53Z", + "lastResult": {"totalInspected": 7, "refreshedCount": 0, "durationMs": 5}}, + gateway={"url": "http://omniroute:20128", "online": True, "statusCode": 200, + "latencyMs": 9, "dbSummary": "Operacional (7 conexoes, 5 combos)"}, + db_path="/app/data/db/data.sqlite", + router_url="http://omniroute:20128", + current_user="admin", + is_default_password=True, + refresh_margin=900, + ) + base.update(overrides) + return render.render_dashboard(**base) + + def test_page_uses_icon_fonts_and_no_emoji(self): + page = self._page() + self.assertIn("bootstrap-icons", page) + self.assertIn('class="bi bi-', page) + found = EMOJI_PATTERN.findall(page) + self.assertEqual(found, [], f"emojis encontrados na interface: {found}") + + def test_page_loads_bootstrap_and_jquery(self): + page = self._page() + self.assertIn("bootstrap@5", page) + self.assertIn("jquery@3", page) + + def test_data_is_embedded_server_side(self): + """A página chega pronta: nada de buscar dados do banco pelo navegador.""" + page = self._page() + self.assertIn("Google Antigravity Pro", page) + self.assertIn("Groq Cloud PathBit", page) + self.assertIn("arsenal-supremo", page) + self.assertNotIn("fetch(", page) + self.assertNotIn("/api/status", page) + + def test_secrets_are_never_rendered(self): + page = self._page() + for secret in ("tok", "ref", "gsk-xxx"): + self.assertNotIn(f">{secret}<", page) + self.assertNotIn("gsk-xxx", page) + + def test_refresh_controls_are_present(self): + page = self._page() + self.assertIn('href="/"', page) # botão Atualizar + self.assertIn('action="/acoes/sincronizar"', page) # Sincronizar agora + self.assertIn('action="/acoes/cron"', page) # Executar ciclo + self.assertIn('action="/acoes/testar-gateway"', page) + + def test_security_banner_appears_only_with_default_password(self): + self.assertIn("Security warning", self._page(is_default_password=True)) + self.assertNotIn("Security warning", self._page(is_default_password=False)) + + def test_default_language_is_english_with_pt_and_es_available(self): + page = self._page() + self.assertIn('lang="en"', page) + self.assertIn("Monitored connections", page) + self.assertIn("flag-icons", page) + for flag in ("fi-us", "fi-br", "fi-es"): + self.assertIn(flag, page) + + def test_page_renders_in_portuguese_and_spanish(self): + self.assertIn("Conexões monitoradas", self._page(lang="pt")) + self.assertIn("Conexiones monitoreadas", self._page(lang="es")) + + def test_local_connection_shows_base_url_and_models(self): + local = make_conn("openai-compatible-chat-ollama-local", "Ollama Local Host", { + "apiKey": "fachada", "baseUrl": "http://localhost:11434/v1", + "discoveredModels": ["llama3.2:3b", "qwen2.5-coder:7b"], + }) + page = self._page(connections=[local]) + self.assertIn("http://localhost:11434/v1", page) + self.assertIn("llama3.2:3b", page) + self.assertIn("bi-hdd-network me-1", page) # tipo renderizado como Local + + def test_cron_history_details_are_available(self): + page = self._page(cron={ + "active": True, "intervalSeconds": 300, "totalRenewals": 0, + "nextRunAt": "2026-09-12T13:51:53Z", + "lastResult": {"totalInspected": 7, "refreshedCount": 0, + "durationMs": 5, "success": False, "error": "gateway offline"}, + "history": [ + {"timestamp": "2026-09-12T13:46:53Z", "totalInspected": 7, "refreshedCount": 0, + "durationMs": 5, "success": False, "error": "gateway offline", + "log": ["antigravity · AG: Validade proxima do fim"]}, + ], + }) + self.assertIn("modalHistorico", page) + self.assertIn("gateway offline", page) + self.assertIn("Validade proxima do fim", page) + self.assertIn("accordion", page) + + def test_cron_failure_is_flagged_on_the_card(self): + page = self._page(cron={ + "active": True, "intervalSeconds": 300, "totalRenewals": 0, + "lastResult": {"totalInspected": 1, "refreshedCount": 0, "durationMs": 3, + "success": False, "error": "boom"}, + "history": [], + }) + self.assertIn("text-bg-danger", page) + self.assertIn("boom", page) + + def test_env_mode_hides_the_password_form(self): + page = self._page(auth_from_env=True) + self.assertNotIn('action="/acoes/credenciais"', page) + self.assertIn("DASHBOARD_USER", page) + + def test_injection_in_connection_name_is_escaped(self): + evil = make_conn("evil", "", {"apiKey": "k"}) + page = self._page(connections=[evil]) + self.assertNotIn("", page) + self.assertIn("<script>", page) + + def test_empty_state_renders(self): + page = self._page(connections=[], combos=[]) + self.assertIn("No connection registered", page) + self.assertIn("No fallback combo", page) + page_pt = self._page(connections=[], combos=[], lang="pt") + self.assertIn("Nenhuma conexão registrada", page_pt) + + +class TestDashboardOverHttp(unittest.TestCase): + """Verifica o SSR de ponta a ponta, com o servidor real no ar.""" + + @classmethod + def setUpClass(cls): + cls.tmp_dir = tempfile.TemporaryDirectory() + cls.db_path = os.path.join(cls.tmp_dir.name, "data.sqlite") + now_ms = int(time.time() * 1000) + expiry_iso = datetime.fromtimestamp( + (now_ms + 24 * 60 * 1000) / 1000, tz=timezone.utc + ).isoformat(timespec="milliseconds").replace("+00:00", "Z") + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT, name TEXT, " + "access_token TEXT, refresh_token TEXT, api_key TEXT, expires_at TEXT, " + "test_status TEXT, created_at TEXT, updated_at TEXT)" + ) + conn.execute( + "CREATE TABLE model_combos (id TEXT PRIMARY KEY, name TEXT, models TEXT, " + "created_at TEXT, updated_at TEXT)" + ) + conn.execute( + "INSERT INTO provider_connections VALUES (?,?,?,?,?,?,?,?,?,?)", + ("c1", "antigravity", "Google Antigravity Pro", "tok", "ref", None, + expiry_iso, "active", "2026-09-12T00:00:00Z", "2026-09-12T00:00:00Z"), + ) + + cls.settings = Settings( + db_path=cls.db_path, web_host="127.0.0.1", web_port=19393, + dashboard_user="admin", dashboard_password="senha-forte", + dashboard_auth_from_env=True, + ) + cls.server = web_server.start_omini_web( + "127.0.0.1", 19393, cls.db_path, omniroute_url="", settings=cls.settings + ) + time.sleep(0.3) + cls.auth = base64.b64encode(b"admin:senha-forte").decode() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.tmp_dir.cleanup() + + def _get(self, path: str): + req = urllib.request.Request(f"http://127.0.0.1:19393{path}") + req.add_header("Authorization", f"Basic {self.auth}") + return urllib.request.urlopen(req, timeout=5) + + def test_dashboard_is_rendered_with_live_data(self): + with self._get("/") as resp: + self.assertEqual(resp.status, 200) + self.assertEqual(resp.headers["Cache-Control"], "no-store, must-revalidate") + self.assertEqual(resp.headers["X-Frame-Options"], "DENY") + body = resp.read().decode("utf-8") + + self.assertIn("Google Antigravity Pro", body) + self.assertIn("bootstrap-icons", body) + self.assertNotIn("tok", body.split(" Date: Sat, 12 Sep 2026 11:08:28 -0300 Subject: [PATCH 03/17] refactor: alinha comentarios e codigos de status ao ingles adotado pelo repo --- src/omini_rtksync/i18n.py | 30 ++++++++++++------------ src/omini_rtksync/models.py | 46 ++++++++++++++++++------------------- src/omini_rtksync/render.py | 12 +++++----- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index aa0727a..1d21956 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -74,12 +74,12 @@ "type.oauth": "OAuth 2.0", "type.api_key": "API key", "type.local": "Local", - "health.ativo": "Active", - "health.expirando_em_breve": "Expiring", - "health.expirado": "Expired", + "health.active": "Active", + "health.expiring_soon": "Expiring", + "health.expired": "Expired", "health.rate_limited": "Rate limited", - "health.sem_expiracao": "No expiry", - "health.desconhecido": "Unknown", + "health.no_expiration": "No expiry", + "health.unknown": "Unknown", "duration.unlimited": "Unlimited / N/A", "duration.expired": "Expired", "reason.api_key": "Static key: never expires, nothing to renew", @@ -153,12 +153,12 @@ "type.oauth": "OAuth 2.0", "type.api_key": "Chave de API", "type.local": "Local", - "health.ativo": "Ativo", - "health.expirando_em_breve": "Expirando", - "health.expirado": "Expirado", + "health.active": "Ativo", + "health.expiring_soon": "Expirando", + "health.expired": "Expirado", "health.rate_limited": "Rate limit", - "health.sem_expiracao": "Sem expiração", - "health.desconhecido": "Desconhecido", + "health.no_expiration": "Sem expiração", + "health.unknown": "Desconhecido", "duration.unlimited": "Ilimitado / N/A", "duration.expired": "Expirado", "reason.api_key": "Chave estática: não expira, nada a renovar", @@ -232,12 +232,12 @@ "type.oauth": "OAuth 2.0", "type.api_key": "Clave de API", "type.local": "Local", - "health.ativo": "Activo", - "health.expirando_em_breve": "Por expirar", - "health.expirado": "Expirado", + "health.active": "Activo", + "health.expiring_soon": "Por expirar", + "health.expired": "Expirado", "health.rate_limited": "Límite de tasa", - "health.sem_expiracao": "Sin expiración", - "health.desconhecido": "Desconocido", + "health.no_expiration": "Sin expiración", + "health.unknown": "Desconocido", "duration.unlimited": "Ilimitado / N/D", "duration.expired": "Expirado", "reason.api_key": "Clave estática: no expira, nada que renovar", diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py index 2d48dd9..a3628cb 100644 --- a/src/omini_rtksync/models.py +++ b/src/omini_rtksync/models.py @@ -1,8 +1,8 @@ -"""Modelo de conexão do OmniRoute, com a mesma interface que o dashboard consome. +"""OmniRoute connection model exposing the same interface the dashboard consumes. -O database.py devolve dicionários (o schema do OmniRoute é relacional). Esta -camada os embrulha num objeto com as propriedades derivadas que a tela precisa, -mantendo o renderizador igual ao do projeto irmão 9RTKSync. +database.py returns dictionaries (the OmniRoute schema is relational). This layer +wraps them in an object carrying the derived properties the screen needs, keeping +the renderer identical to the sibling project 9RTKSync. """ import time @@ -11,17 +11,17 @@ from .normalizer import parse_expiry_to_ms -# Nomes de provedor que identificam uma instância local / compatível com OpenAI. +# Provider names that identify a local / OpenAI-compatible instance. LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal") -# Margem abaixo da qual o token é considerado "expirando em breve" (15 min). +# Threshold below which a token counts as "expiring soon" (15 min). EXPIRING_SOON_SECONDS = 900 @dataclass class ConnectionRecord: - """Uma linha de provider_connections vista pela ótica do painel.""" + """A provider_connections row seen through the panel's lens.""" id: str provider: str @@ -30,7 +30,7 @@ class ConnectionRecord: @classmethod def from_row(cls, row: Dict[str, Any]) -> "ConnectionRecord": - """Constrói o registro a partir do dicionário devolvido por get_all_connections.""" + """Build the record from the dictionary returned by get_all_connections.""" return cls( id=str(row.get("id", "")), provider=str(row.get("provider", "")), @@ -52,10 +52,10 @@ def api_key(self) -> Optional[str]: @property def is_local(self) -> bool: - """Indica se a conexão aponta para uma instância local. + """Whether the connection points at a local instance. - Uma instância local costuma exigir uma chave de API de fachada, então - checar apenas has_api_key a classificaria como provedor de nuvem. + A local instance usually needs a facade API key, so checking has_api_key + alone would classify it as a cloud provider. """ provider = self.provider.lower() if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): @@ -76,7 +76,7 @@ def base_url(self) -> Optional[str]: @property def local_models(self) -> List[str]: - """Modelos descobertos na instância local na última varredura.""" + """Models discovered on the local instance during the last sweep.""" models = self.data.get("discoveredModels") or self.data.get("models") or [] if isinstance(models, str): return [models] @@ -84,7 +84,7 @@ def local_models(self) -> List[str]: @property def expires_at_ms(self) -> Optional[int]: - """Expiração normalizada em epoch milissegundos, seja ISO ou numérica.""" + """Expiry normalized to epoch milliseconds, whether ISO or numeric.""" return parse_expiry_to_ms(self.data.get("expiresAt")) @property @@ -96,25 +96,25 @@ def remaining_seconds(self) -> Optional[int]: @property def health_status(self) -> str: - """Classificação semântica do estado da conexão.""" + """Semantic classification of the connection state.""" if self.is_local: - # unreachable é gravado quando o catálogo de modelos não responde. - return "desconhecido" if self.data.get("testStatus") == "unreachable" else "ativo" + # unreachable is written when the model catalog does not answer. + return "unknown" if self.data.get("testStatus") == "unreachable" else "active" if self.is_oauth: remaining = self.remaining_seconds if remaining is None: - return "sem_expiracao" + return "no_expiration" if remaining <= 0: - return "expirado" + return "expired" if remaining < EXPIRING_SOON_SECONDS: - return "expirando_em_breve" - return "ativo" + return "expiring_soon" + return "active" if self.has_api_key: if self.data.get("rateLimitedUntil"): return "rate_limited" - return "ativo" + return "active" - # O OmniRoute usa "active"; o 9Router usa "ok". Ambos significam saudável. - return "ativo" if self.data.get("testStatus") in ("active", "ok") else "desconhecido" + # OmniRoute writes "active"; 9Router writes "ok". Both mean healthy. + return "active" if self.data.get("testStatus") in ("active", "ok") else "unknown" diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 800c8cb..c0486fa 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -23,12 +23,12 @@ # Estado semântico -> (classe do badge, ícone) HEALTH_PRESENTATION = { - "ativo": ("text-bg-success", "bi-check-circle-fill"), - "expirando_em_breve": ("text-bg-warning", "bi-hourglass-split"), - "expirado": ("text-bg-danger", "bi-x-octagon-fill"), + "active": ("text-bg-success", "bi-check-circle-fill"), + "expiring_soon": ("text-bg-warning", "bi-hourglass-split"), + "expired": ("text-bg-danger", "bi-x-octagon-fill"), "rate_limited": ("text-bg-warning", "bi-pause-circle-fill"), - "sem_expiracao": ("text-bg-secondary", "bi-infinity"), - "desconhecido": ("text-bg-secondary", "bi-question-circle-fill"), + "no_expiration": ("text-bg-secondary", "bi-infinity"), + "unknown": ("text-bg-secondary", "bi-question-circle-fill"), } @@ -97,7 +97,7 @@ def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LA def health_badge(status: str, lang: str) -> str: """Monta o badge de saúde com ícone de fonte.""" - css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["desconhecido"]) + css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["unknown"]) label = translate(f"health.{status}", lang) return ( f'' From 623706108917e6eee42e77fe8fc7bd7015c6830e Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 11:15:24 -0300 Subject: [PATCH 04/17] docs: wiki versionada no repo com publicacao automatica A wiki do GitHub nao passa por review: quem tem acesso edita direto e nao ha diff, historico util nem gate. As paginas passam a viver em docs/wiki/ e sao publicadas na wiki por um workflow a cada push em master. Paginas: Home, Installation, Configuration, Dashboard, Authentication, Logging, Architecture, Troubleshooting e Upstream-Fixes, alem de _Sidebar e _Footer. Configuration documenta o contrato completo de variaveis de ambiente - o requisito de operar sem nunca abrir a tela. Troubleshooting parte de sintomas reais (o BrokenPipeError do healthz, "o cron nao renovou o token", o Ollama local sem modelos, senha esquecida). Upstream-Fixes registra os bugs achados nos dois gateways e os PRs enviados, incluindo a correcao da afirmacao do README antigo sobre o expiresAt em ISO, que a leitura do codigo upstream nao sustenta. Primeira execucao do workflow exige que a wiki ja exista: o GitHub so cria o repositorio .wiki.git depois que a primeira pagina e salva pela interface. O job avisa isso em vez de falhar. --- .github/workflows/publish-wiki.yml | 62 ++++++++++++ README.md | 10 ++ docs/wiki/Architecture.md | 122 ++++++++++++++++++++++++ docs/wiki/Authentication.md | 108 +++++++++++++++++++++ docs/wiki/Configuration.md | 117 +++++++++++++++++++++++ docs/wiki/Dashboard.md | 120 ++++++++++++++++++++++++ docs/wiki/Home.md | 70 ++++++++++++++ docs/wiki/Installation.md | 145 ++++++++++++++++++++++++++++ docs/wiki/Logging.md | 90 ++++++++++++++++++ docs/wiki/Troubleshooting.md | 146 +++++++++++++++++++++++++++++ docs/wiki/Upstream-Fixes.md | 98 +++++++++++++++++++ docs/wiki/_Footer.md | 1 + docs/wiki/_Sidebar.md | 15 +++ 13 files changed, 1104 insertions(+) create mode 100644 .github/workflows/publish-wiki.yml create mode 100644 docs/wiki/Architecture.md create mode 100644 docs/wiki/Authentication.md create mode 100644 docs/wiki/Configuration.md create mode 100644 docs/wiki/Dashboard.md create mode 100644 docs/wiki/Home.md create mode 100644 docs/wiki/Installation.md create mode 100644 docs/wiki/Logging.md create mode 100644 docs/wiki/Troubleshooting.md create mode 100644 docs/wiki/Upstream-Fixes.md create mode 100644 docs/wiki/_Footer.md create mode 100644 docs/wiki/_Sidebar.md diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml new file mode 100644 index 0000000..c73fe38 --- /dev/null +++ b/.github/workflows/publish-wiki.yml @@ -0,0 +1,62 @@ +name: Publish Wiki + +# The wiki is generated from docs/wiki/ so the documentation is reviewed in pull +# requests like any other change, instead of being edited straight in the wiki +# where nothing gates it. +# +# First run requires the wiki to already exist: GitHub only creates the +# .wiki.git repository after the first page is saved through the web UI. +# Create any page once and this workflow takes over from there. + +on: + push: + branches: [master] + paths: + - "docs/wiki/**" + - ".github/workflows/publish-wiki.yml" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: publish-wiki + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Clone the wiki + id: clone + run: | + if git clone "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git" wiki; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + echo "::warning::Wiki repository not found. Create the first page through the GitHub UI once, then re-run this workflow." + fi + + - name: Sync pages + if: steps.clone.outputs.ok == 'true' + run: | + # Replace the whole page set so a deleted source file disappears from the wiki too. + find wiki -maxdepth 1 -name '*.md' -delete + cp docs/wiki/*.md wiki/ + + - name: Commit and push + if: steps.clone.outputs.ok == 'true' + working-directory: wiki + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Wiki already up to date." + exit 0 + fi + git commit -m "docs: sync wiki from ${{ github.sha }}" + git push diff --git a/README.md b/README.md index eafdfb5..ce89f6b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ O **`OminiRTKSync`** (*OminiRoute Universal Token & Connection Synchronizer*) é Caso esteja utilizando o 9Router original, utilize o projeto irmão [9RTKSync](https://github.com/pathbit/9RTKSync) configurado para a arquitetura do [9Router](https://github.com/decolua/9router). + +## Documentation + +The full documentation lives in the [project wiki](../../wiki): installation, the complete +environment-variable contract, the dashboard, authentication and break-glass recovery, +persistent logging, architecture, troubleshooting, and the upstream gateway fixes. + +Wiki pages are generated from [`docs/wiki/`](docs/wiki) — edit them there and open a pull +request; a push to `master` republishes the wiki automatically. + --- ## Recursos Principais diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md new file mode 100644 index 0000000..20aa240 --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,122 @@ +# Architecture + +OminiRTKSync is a sidecar. It shares the OmniRoute SQLite file through a Docker volume and repairs the +state the gateway keeps about its own connections. + +``` + ┌───────────────────────────────┐ + │ host home (read-only mount) │ + │ ~/.gemini, ~/.config/… │ + └───────────────┬───────────────┘ + │ discovery + ▼ + ┌────────────┐ ┌───────────┐ shared volume ┌──────────────┐ + │ browser │──►│ OminiRTKSync │◄─────────────────►│ data.sqlite │ + │ :9092 │ │ :9090 │ │ providerConn │ + └────────────┘ └─────┬─────┘ └──────▲───────┘ + │ HTTP probe │ + ▼ │ + ┌───────────┐ │ + │ OmniRoute │──────────────────────────┘ + │ :20128 │ + └───────────┘ +``` + +--- + +## Modules + +| Module | Responsibility | +| :--- | :--- | +| `cli.py` | Argument parsing, bootstrap of logging and the recovery hash, entry points. | +| `daemon.py` | `SyncEngine.sync_all()` — one full pass over every connection. | +| `cron.py` | Background scheduler; keeps per-cycle history with the actions each produced. | +| `database.py` | SQLite reads and writes against `provider_connections` and `combos`. | +| `models.py` | `ConnectionRecord` and its derived properties (`is_oauth`, `is_local`, `remaining_seconds`, `health_status`). | +| `normalizer.py` | Credential-format self-healing and stale-lock removal. | +| `discovery.py` | Finds provider credentials on the host filesystem. | +| `providers/` | One handler per credential family: Google, generic OAuth, API key, local. | +| `combos.py` | Keeps the fallback combos registered and up to date. | +| `web.py` | HTTP server, routing, actions. | +| `render.py` | Server-side HTML rendering. | +| `i18n.py`, `prefs.py` | Interface language and its SQLite persistence. | +| `auth.py`, `logs.py` | Credential rules and the persistent file log. | + +--- + +## One synchronization pass + +`SyncEngine.sync_all()` per connection: + +1. **Self-heal the format.** `normalize_connection_data()` converts `expiresAt` into the numeric + epoch the gateway's own readers expect, derives it from `expiresIn` when absent, drops expired + `rateLimitedUntil` (resetting `backoffLevel`) and removes expired `modelLock_*` entries. +2. **Pick a handler.** The first provider whose `can_handle()` matches wins: + + | Handler | Matches | + | :--- | :--- | + | `GoogleProvider` | Antigravity, Gemini CLI | + | `GenericOAuthProvider` | Claude, Copilot, Codex, Kiro, Windsurf and other OAuth families | + | `ApiKeyProvider` | Static API keys | + | `LocalProvider` | Ollama, vLLM, LM Studio, OpenAI-compatible, any local `baseUrl` | + +3. **Renew or probe.** OAuth handlers renew when the remaining validity drops below + `REFRESH_MARGIN`, or when the connection carries an error or lock. `LocalProvider` queries the + instance's model catalog. `ApiKeyProvider` checks liveness. +4. **Write back.** Only when something actually changed. + +Every action is recorded twice: in the file log, and in the cycle entry the dashboard's **Logs** +button shows. + +--- + +## Credential discovery on the host + +`HostDiscoveryEngine` scans the read-only host mount for provider credential files — Antigravity +and Gemini CLI tokens under `~/.gemini/` and `~/.config/antigravity/`, plus any path given in +`ANTIGRAVITY_TOKEN_PATH`. A fresher refresh token found on the host is promoted into the gateway +connection, which is what lets a local `gemini auth login` heal a stale gateway account. + +--- + +## Interoperating with the gateway's format + +The synchronizer and the gateway share a database, so they must agree on how values are shaped. +Two conventions matter: + +- **`expires_at`.** A TEXT column read with `new Date(...)`, so a numeric epoch written as text + becomes an Invalid Date and the gateway concludes the connection has no known expiry. + OminiRTKSync writes **ISO-8601 UTC**. +- **`test_status`.** OmniRoute only treats `"active"` as healthy; `"ok"` is not recognised and + makes the connection look like it is in an error state. + +The sibling project targets a JSON-column schema where a number survives the round-trip, and the +rules are the opposite. Getting this wrong silently disables the gateway's proactive refresh — +see [Upstream Fixes](Upstream-Fixes). + +--- + +## Web layer + +- `ThreadingHTTPServer` with `daemon_threads`. A single-threaded server meant one slow request + blocked the health probe. +- The gateway probe is cached for 30 s, so `/healthz` does not cost an outbound HTTP call per + call. +- Client disconnects (`BrokenPipe`, `ConnectionReset`, `ConnectionAborted`) are swallowed; real + errors still reach the default handler. +- Every page is built by `render.py` with the data already embedded — the database never leaves + the server process. +- Actions are POST-Redirect-GET under `/acoes/*`. + +--- + +## State owned by the synchronizer + +Written next to the database (or `DATA_DIR`), never inside the gateway's schema: + +| File | Contents | +| :--- | :--- | +| `.dashboard_auth.json` | Credentials set from the screen. | +| `.dashboard_recovery` | Break-glass hash, mode `0600`. | +| `ui_prefs.sqlite` | Interface language. | +| `logs/ominirtksync.log` | Rotating persistent log. | diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md new file mode 100644 index 0000000..cb0a1e8 --- /dev/null +++ b/docs/wiki/Authentication.md @@ -0,0 +1,108 @@ +# Authentication + +The dashboard is protected by HTTP Basic Auth. Three credential sources are evaluated in a fixed +order, implemented in [`src/omini_rtksync/auth.py`](https://github.com/pathbit/OminiRTkSync/blob/master/src/omini_rtksync/auth.py) +and covered by `tests/test_auth_recovery.py`. + +--- + +## The rule + +A sign-in attempt is accepted when **any** of these holds: + +1. **Stored credentials match.** Once the password has been changed from the screen, those saved + credentials are the only normal way in — the factory defaults stop working. +2. **Factory credentials match, and nothing has been stored yet.** This is the first-boot state + of a fresh container. +3. **The user is `admin` and the password equals the recovery hash.** This always works, whatever + is stored. It is the break-glass path. + +Anything else is invalid. All comparisons use `hmac.compare_digest`, so a wrong password does not +leak information through response timing. + +``` + ┌──────────────────────────┐ + admin + hash ────►│ always accepted │ + └──────────────────────────┘ + ┌──────────────────────────┐ + stored exists ───►│ only stored credentials │ + └──────────────────────────┘ + ┌──────────────────────────┐ + nothing stored ──►│ factory credentials │ + └──────────────────────────┘ +``` + +--- + +## Factory credentials + +`admin` / `pathbit`, overridable with `DASHBOARD_USER` and `DASHBOARD_PASSWORD`. + +While the password is still `pathbit`, the dashboard shows a security banner. Change it — the +panel reaches your gateway's credential store. + +--- + +## Headless mode + +Setting `DASHBOARD_USER` and/or `DASHBOARD_PASSWORD` in the environment makes them the **source +of truth**: + +- The `.dashboard_auth.json` file written by the screen is ignored. +- Changing the password from the panel answers `409 Conflict`, with a message saying where the + credentials come from. + +Without this, a single password change through the screen would leave both variables permanently +inert — the file would win forever, and a redeployed container would keep the old password. + +To hand control back to the dashboard, remove both variables and restart. + +--- + +## Break-glass recovery + +If the screen password is lost, sign in with user **`admin`** and the **recovery hash** as the +password. + +**Where the hash comes from** + +1. `DASHBOARD_RECOVERY_HASH`, if set. Pin your own value here for reproducible deployments. +2. Otherwise a random value generated on first boot, written to `.dashboard_recovery` next to the + other panel state files, with mode `0600`, and logged **once** at `WARNING`: + +``` +[AUTH] Recovery hash generated. To recover access use user 'admin' and this password: + a3f1... (keep it safe; set DASHBOARD_RECOVERY_HASH to pin your own) +``` + +**Retrieving it later** + +```bash +docker logs ominirtksync 2>&1 | grep "Recovery hash" +docker exec ominirtksync cat /app/data/.dashboard_recovery +``` + +**Notes** + +- The recovery path only accepts the user `admin`. The hash alone, with any other user, is + rejected. +- An empty recovery hash never grants access — a blank password cannot become a master key. +- If the directory is not writable the hash is generated in memory and lives only for that + process run; the service still starts. + +--- + +## Hardening + +The panel and the SQLite database it reads must never be reachable from the internet. + +- Publish the port on loopback only: `"127.0.0.1:9092:9090"`. The shipped compose example already + does this. +- The page itself is served with `Cache-Control: no-store`, `X-Frame-Options: DENY`, + `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. +- `/api/status` no longer sends `Access-Control-Allow-Origin: *`, so another site cannot read it + from a browser. +- The rendered page never contains access tokens, refresh tokens or API keys — only provider, + name, type, health and remaining validity. +- If you need remote access, put it behind a VPN or an authenticating reverse proxy. Do not + expose port 9092 directly. diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..ddfdc08 --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,117 @@ +# Configuration + +Everything is reachable from the environment. You never have to open the dashboard to configure +the service — that is a hard contract, covered by `tests/test_config_env.py`. + +Values are read from the process environment first, then from a `.env` file in the working +directory (`load_dotenv` never overwrites a variable that is already set). + +--- + +## Database and host discovery + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `DB_PATH` | auto-detected | Path to the OmniRoute SQLite file. When unset, the first existing candidate wins: `/app/data/storage.sqlite`, `/app/data/data.sqlite`, `/app/data/storage.sqlite`, `~/.omniroute/data/storage.sqlite`, `~/.omniroute/data.sqlite`. | +| `HOST_HOME` | auto-detected | Host home directory mounted into the container. Falls back to `/root/host`, then `/host`, then the process home. | +| `DATA_DIR` | — | Base directory for the panel's own state files (`.dashboard_auth.json`, `.dashboard_recovery`, `ui_prefs.sqlite`). Defaults to the directory holding `DB_PATH`. | +| `ANTIGRAVITY_TOKEN_PATH` | — | Extra path to an Antigravity/Gemini credential file, searched before the built-in list. | +| `MODULE` | `all` | Which combos to sync: `all`, `antigravity`, `oauth`, `gemini`. | + +--- + +## Gateway connectivity + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `OMNIROUTE_URL` | `http://127.0.0.1:20128` | Base URL of the OmniRoute gateway, used by `/healthz` and by the **Test connection** button. | + +The gateway probe result is cached for 30 seconds. Without that cache, every Docker health check +would pay an outbound HTTP call of up to 3 seconds — which is what used to make the probe time +out and produce `BrokenPipeError` in the logs. + +--- + +## Synchronization and scheduling + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `SYNC_INTERVAL` | `300` | Seconds between synchronization passes. | +| `REFRESH_MARGIN` | `900` | Seconds of remaining validity below which a token is renewed. | +| `CRON_INTERVAL` | inherits `SYNC_INTERVAL` | Dedicated interval for the scheduler, when you want it to differ from the sync pass. | +| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Run now** button, or `POST /api/sync`). | + +> **A token is only renewed inside the margin.** With the defaults, a token with 24 minutes left +> is *not* renewed, because 24 min > 15 min. That is correct behavior, not a failure — the +> dashboard states the reason per connection. See [Troubleshooting](Troubleshooting). + +--- + +## Web dashboard + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `ENABLE_WEB_DASHBOARD` | `1` | `0` runs the synchronizer headless, with no HTTP server at all. | +| `WEB_HOST` | `0.0.0.0` | Listen interface **inside** the container. Keep the published port bound to `127.0.0.1` on the host. | +| `WEB_PORT` | `9090` | Internal port. Identical in both synchronizers; the published host port is what differs (`9092` here, `9091` for 9RTKSync). | + +--- + +## Authentication + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `DASHBOARD_USER` | `admin` | Panel user. | +| `DASHBOARD_PASSWORD` | `pathbit` | Panel password. **Change it.** | +| `DASHBOARD_RECOVERY_HASH` | generated | Break-glass credential: sign in as `admin` with this value as the password. When unset, a random value is generated on first boot, stored with mode `0600` and written once to the log. | + +**Headless mode.** Setting `DASHBOARD_USER` and/or `DASHBOARD_PASSWORD` makes the environment the +source of truth: the `.dashboard_auth.json` file written by the screen is ignored, and changing +the password from the panel answers `409 Conflict`. Comment both variables out to hand control +back to the dashboard. + +Full rules in [Authentication](Authentication). + +--- + +## Logging + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LOG_DIR` | `/logs` | Directory for log files. Falls back to `~/.ominirtksync/logs`. | +| `LOG_RETENTION_DAYS` | `30` | Days before rotated files are purged. Minimum `1`. | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR`. | +| `LOG_TO_STDOUT` | `1` | `0` stops mirroring events on the container stdout. | + +Details in [Logging](Logging). + +--- + +## CLI overrides + +Command-line flags take precedence over the environment for a single run: + +```bash +OminiRTKSync --status --db-path /path/to/data.sqlite +OminiRTKSync --once --db-path /path/to/data.sqlite +OminiRTKSync --daemon --db-path /path/to/data.sqlite --interval 60 --margin 1200 --port 9090 +OminiRTKSync --daemon --no-web +``` + +--- + +## Fully headless example + +No dashboard, no interactive setup, scheduler on a one-minute cadence, logs kept for 90 days: + +```yaml +environment: + - DB_PATH=/app/data/storage.sqlite + - OMNIROUTE_URL=http://omniroute:20128 + - SYNC_INTERVAL=60 + - REFRESH_MARGIN=1200 + - ENABLE_WEB_DASHBOARD=0 + - LOG_DIR=/app/data/logs + - LOG_RETENTION_DAYS=90 + - LOG_TO_STDOUT=0 +``` diff --git a/docs/wiki/Dashboard.md b/docs/wiki/Dashboard.md new file mode 100644 index 0000000..5716ea5 --- /dev/null +++ b/docs/wiki/Dashboard.md @@ -0,0 +1,120 @@ +# Dashboard + +The panel is **rendered on the server**. The HTML arrives with the data already embedded; the +browser never queries the SQLite database, and the page works with JavaScript disabled. jQuery +and Bootstrap only provide comfort — modals, the dropdown, and disabling a button once clicked. + +Reachable at **http://localhost:9092** (internal port 9090), behind HTTP Basic Auth. + +--- + +## Layout + +| Section | What it shows | +| :--- | :--- | +| Security banner | Only while the factory password is still in use. | +| Metric cards | Total connections, OAuth accounts, API keys, registered combos. | +| Gateway card | Gateway URL, HTTP status, latency, database summary, **Test connection**. | +| Scheduler card | State, next run, tokens renewed, last result, **Logs**, **Run now**. | +| Connections table | Provider, name, type, health, remaining validity, **renewal diagnosis**. | +| Resilience combos | Registered combos and their model cascade. | + +--- + +## Refreshing + +Every control is a real HTTP request that redirects back to the freshly rendered page +(POST-Redirect-GET), so what you see after an action is the new state, never a cached one. + +| Control | Effect | +| :--- | :--- | +| **Refresh** | Plain link to `/`; re-reads the database and re-renders. | +| **Sync now** | Runs a full synchronization pass, then reports what changed. | +| **Run now** | Triggers one scheduler cycle immediately. | +| **Test connection** | Invalidates the 30 s probe cache and really calls the gateway. | + +The page is served with `Cache-Control: no-store, must-revalidate`, so a browser reload always +hits the server. + +--- + +## Renewal diagnosis + +The single most useful column. Previously the panel showed only `0 renewed`, with no way to tell +"nothing needed renewing" from "renewal failed". Now each connection carries the reason: + +| Diagnosis | Meaning | +| :--- | :--- | +| `Outside the 15 min margin: renewal expected in ~9 min` | Healthy. The token is still far from expiry. | +| `Within the 15 min margin: will be renewed on the next sweep` | Renewal is due and will happen. | +| `Token expired: renewal will be attempted on the next sweep` | Past due — check the scheduler logs if it persists. | +| `No expiry recorded: will be renewed on the next sweep` | The gateway did not store a readable expiry. | +| `Static key: never expires, nothing to renew` | API-key provider. | +| `Local instance answered with 4 model(s)` | Local provider, reachable. | +| `Local instance did not answer the model catalog` | Local provider down. | + +The margin comes from `REFRESH_MARGIN`. + +--- + +## Scheduler logs + +**Logs** on the scheduler card opens the per-cycle history. Each entry expands to the actions +that cycle produced — renewals, self-healing, provider errors. A cycle that failed is flagged in +red, both in the list and with a badge on the button itself. + +A cycle with nothing to do shows as exactly that, rather than an empty screen you have to guess +about. + +The in-memory history keeps the last cycles; the durable record is the file log +(see [Logging](Logging)). + +--- + +## Language + +Default **English**, with **Português** and **Español** in the flag dropdown (real flag icons from +`flag-icons`, not emoji). + +The choice is persisted in **SQLite** — `ui_prefs.sqlite`, a database of the synchronizer's own, +next to the other panel state files. Never in the gateway's database (that would couple our schema +to theirs), and never in `localStorage` (which dies with the browser profile). + +A missing translation key falls back to English, never to the raw key. + +--- + +## Local providers + +An Ollama, vLLM or LM Studio instance usually needs a facade API key, which used to make it show +up as a cloud provider. It is now recognised as **Local** and its row carries the `baseUrl` and +the models the instance actually serves, discovered through `/api/tags` or `/v1/models`. + +An instance that stops answering is marked `unreachable` and shows the **Unknown** badge, instead +of being assumed healthy. + +--- + +## Security + +- No access token, refresh token or API key is ever rendered. +- `Cache-Control: no-store`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, + `Referrer-Policy: no-referrer`. +- `/api/status` no longer sends `Access-Control-Allow-Origin: *`. +- Publish the port on `127.0.0.1` only. + +More in [Authentication](Authentication). + +--- + +## JSON endpoints + +Kept for automation; the dashboard itself does not use them. + +| Endpoint | Method | Purpose | +| :--- | :--- | :--- | +| `/healthz` | GET | Unauthenticated liveness probe. `OK`, `DATABASE_NOT_READY` or `OMNIROUTE_SERVICE_UNREACHABLE`. | +| `/api/status` | GET | Full state as JSON. | +| `/api/cron-status` | GET | Scheduler state and history. | +| `/api/sync` | POST | Trigger a synchronization pass. | +| `/api/cron-run` | POST | Trigger one scheduler cycle. | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..db89a03 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,70 @@ +# OminiRTKSync + +**OmniRoute Universal Token & Connection Synchronizer** — a high-availability guardian for +[OmniRoute](https://github.com/diegosouzapw/OmniRoute) gateways. It keeps OAuth accounts alive, heals +credential formats the gateway cannot read, clears stale rate-limit locks, and reports exactly +why each connection was or was not renewed. + +This wiki is generated from [`docs/wiki/`](https://github.com/pathbit/OminiRTkSync/tree/master/docs/wiki) +in the main repository. Edit the files there and open a pull request — a push to `master` +republishes these pages automatically. Editing a page directly here will be overwritten. + +--- + +## Pages + +| Page | What it covers | +| :--- | :--- | +| [Installation](Installation) | Docker Compose and local virtual environment | +| [Configuration](Configuration) | Every environment variable — the full headless contract | +| [Dashboard](Dashboard) | The server-rendered panel, language switcher, cron logs | +| [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 | +| [Troubleshooting](Troubleshooting) | Concrete symptoms and what they actually mean | +| [Upstream Fixes](Upstream-Fixes) | Bugs found in the gateways and the patches sent upstream | + +--- + +## What it does + +**Credential format healing.** OmniRoute keeps `expires_at` in a TEXT column and reads it with +`new Date(...)`. A value in a shape that parser rejects silently stops the gateway's proactive +refresh for that connection, and the account 401s until someone re-authenticates by hand. +OminiRTKSync writes ISO-8601 there, the gateway's own native format. + +**Proactive OAuth renewal.** Google Antigravity and Gemini CLI tokens are refreshed before they +expire, using a configurable margin (`REFRESH_MARGIN`, default 15 minutes). Credentials found on +the host (`~/.gemini/`, `~/.config/antigravity/`) are picked up and synced into the gateway. + +**Rate-limit unlocking.** Expired `rateLimitedUntil` locks and stale `modelLock_*` entries are +removed, and `backoffLevel` is reset, so a connection stops being skipped once its cooldown has +actually passed. + +**Local provider health.** Ollama, vLLM, LM Studio and any OpenAI-compatible local instance are +probed for their model catalog. A local instance that stops answering is marked `unreachable` +instead of being assumed healthy. + +**Server-rendered dashboard.** Port `9090` inside the container (published on `9092`), bound to +loopback. The page is assembled on the server with the data already embedded — the browser never +queries the SQLite database. + +--- + +## The sibling project + +If you run the original [9Router](https://github.com/decolua/9router) instead of OmniRoute, use +[9RTKSync](https://github.com/pathbit/9RTKSync), which targets that gateway's JSON-column +schema. The two projects share the same dashboard, logging, authentication and configuration +contract; only the database layer and the provider set differ. + +Both synchronizers listen on **port 9090 inside their container**. The published host ports +differ so they can run side by side: `9091` for 9RTKSync, `9092` for OminiRTKSync. + +--- + +## License + +MIT — see [LICENSE](https://github.com/pathbit/OminiRTkSync/blob/master/LICENSE). + +Built by [Pathbit](https://pathbit.co/). diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md new file mode 100644 index 0000000..7bbdc7c --- /dev/null +++ b/docs/wiki/Installation.md @@ -0,0 +1,145 @@ +# Installation + +Two supported paths: Docker (recommended) and a local Python virtual environment. + +--- + +## Docker Compose + +The official image is published to GHCR by GitHub Actions: + +```bash +docker pull ghcr.io/pathbit/ominirtksync:latest +``` + +A working `docker-compose.yml` alongside the gateway: + +```yaml +name: omniroute-stack + +services: + omniroute: + image: diegosouzapw/OmniRoute:latest + container_name: omniroute + restart: unless-stopped + ports: + - "127.0.0.1:20128:20128" + environment: + - DATA_DIR=/app/data + - PORT=20128 + - HOSTNAME=0.0.0.0 + volumes: + - omniroute_data:/app/data + + ominirtksync: + image: ghcr.io/pathbit/ominirtksync:latest + container_name: ominirtksync + restart: unless-stopped + ports: + # Internal port 9090 (same in OminiRTKSync); published on 9092. + # The 127.0.0.1 bind keeps the panel and the SQLite file off the internet. + - "127.0.0.1:9092:9090" + volumes: + - omniroute_data:/app/data + - ${HOME}:/root/host:ro + - ominirtksync_logs:/app/data/logs + environment: + - HOST_HOME=/root/host + - DB_PATH=/app/data/storage.sqlite + - OMNIROUTE_URL=http://omniroute:20128 + - SYNC_INTERVAL=300 + - REFRESH_MARGIN=900 + - WEB_PORT=9090 + - DASHBOARD_USER=admin + - DASHBOARD_PASSWORD=change-me + - LOG_DIR=/app/data/logs + - LOG_RETENTION_DAYS=30 + depends_on: + - omniroute + healthcheck: + test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + omniroute_data: + ominirtksync_logs: +``` + +Then open **http://localhost:9092**. + +### Why these details matter + +- **`omniroute_data` is shared.** The synchronizer reads and writes the same SQLite file the + gateway uses; without the shared volume it has nothing to heal. +- **`${HOME}` is mounted read-only.** Antigravity and Gemini CLI credentials live in the host + home (`~/.gemini/`, `~/.config/antigravity/`). Read-only is enough — the synchronizer never + writes there. +- **The port is bound to `127.0.0.1`.** The panel reads credential metadata; it must not be + reachable from the internet. +- **A named volume for the logs.** Otherwise they die with the container. See [Logging](Logging). + +--- + +## Local virtual environment + +Requires Python 3.11+ (3.14 is what CI pins). + +```bash +git clone https://github.com/pathbit/OminiRTkSync.git +cd OminiRTKSync + +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -e . +``` + +### Commands + +```bash +# Connection and combo status, no changes written +OminiRTKSync --status --db-path ~/.omniroute/data/storage.sqlite + +# One immediate synchronization pass +OminiRTKSync --once --db-path ~/.omniroute/data/storage.sqlite + +# Continuous daemon with the dashboard +OminiRTKSync --daemon --db-path ~/.omniroute/data/storage.sqlite + +# Daemon without the web server +OminiRTKSync --daemon --no-web +``` + +`--db-path` is optional: without it the synchronizer probes the usual locations. See +[Configuration](Configuration). + +--- + +## Running the tests + +```bash +source .venv/bin/activate +PYTHONPATH=src python3 -m unittest discover -s tests -p "test_*.py" +``` + +Or with no local install at all: + +```bash +./run_tests.sh +``` + +--- + +## Upgrading + +```bash +docker compose pull ominirtksync +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. diff --git a/docs/wiki/Logging.md b/docs/wiki/Logging.md new file mode 100644 index 0000000..2c84f02 --- /dev/null +++ b/docs/wiki/Logging.md @@ -0,0 +1,90 @@ +# Logging + +Container stdout is volatile: it disappears on `docker rm`, gets truncated by the log driver and +does not survive a restart. Events that matter for auditing — token renewals, sync failures, +dashboard access — are therefore also written to a file, with daily rotation and age-based purge. + +Implemented in [`src/omini_rtksync/logs.py`](https://github.com/pathbit/OminiRTkSync/blob/master/src/omini_rtksync/logs.py), +covered by `tests/test_logs.py`. + +--- + +## Configuration + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LOG_DIR` | `/logs` | Destination directory. Falls back to `~/.ominirtksync/logs` when the database directory is not writable. | +| `LOG_RETENTION_DAYS` | `30` | Days a rotated file is kept. Minimum `1`; an unparseable value falls back to 30. | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`. | +| `LOG_TO_STDOUT` | `1` | `0` stops mirroring on stdout. The file keeps receiving everything. | + +--- + +## Rotation and retention + +- One file, `ominirtksync.log`, rotated at **UTC midnight**. +- Rotated files are named `ominirtksync.log.YYYY-MM-DD`. +- `backupCount` equals `LOG_RETENTION_DAYS`, so daily rotation keeps exactly that many days. +- On every startup, `purge_expired_logs()` also deletes rotated files whose modification time is + older than the retention window. This catches files left behind by a container that was down + for a while. + +**Never touched:** the active `ominirtksync.log`, and any file that does not belong to this service. +Another service's logs sharing the same directory are left alone. + +``` +/app/data/logs/ + ominirtksync.log ← active, never purged + ominirtksync.log.2026-09-11 ← kept (2 days old) + ominirtksync.log.2026-07-01 ← purged (73 days old, retention 30) + other-service.log.2026-01-01 ← left alone, not ours +``` + +--- + +## Keeping logs longer + +```yaml +environment: + - LOG_RETENTION_DAYS=90 +volumes: + - ominirtksync_logs:/app/data/logs +``` + +Mount a named volume (or a host path) or the files die with the container, which defeats the +purpose. + +--- + +## Format + +``` +[2026-09-12 13:46:53] [INFO] [CRON] Cycle triggered (scheduled_interval). Inspecting OAuth account connections... +[2026-09-12 13:46:53] [INFO] [STATUS] [antigravity · Google Antigravity Pro] Token valid for another 24 min +[2026-09-12 13:46:53] [INFO] [CRON] Cycle completed in 5ms: 7 accounts evaluated, 0 renewed via OAuth. +[2026-09-12 13:51:58] [WARNING] [AUTH] Recovery hash generated. To recover access use user 'admin' ... +``` + +Prefixes: `CRON`, `STATUS`, `SYNC`, `CURA` (self-healing), `DISCOVERY`, `AUTH`, `ERRO`, `FALHA`. + +--- + +## Failure behaviour + +The file log is **best effort**. If the directory cannot be created or written, the service still +starts and prints once to stderr: + +``` +[LOG] File log unavailable at /app/data/logs: [Errno 13] Permission denied +``` + +A synchronizer that refuses to run because it cannot write a log file would be worse than one +that runs without the log. + +--- + +## Per-cycle logs in the dashboard + +Separately from the file log, the scheduler keeps the last cycles in memory with the actions each +one produced. The **Logs** button on the scheduler card opens the history; a failed cycle is +flagged in red and its error is shown inline. See [Dashboard](Dashboard). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md new file mode 100644 index 0000000..f103b0d --- /dev/null +++ b/docs/wiki/Troubleshooting.md @@ -0,0 +1,146 @@ +# Troubleshooting + +Concrete symptoms, what they actually mean, and what to do. + +--- + +## "The cron ran several times and never renewed the Antigravity token" + +**Usually not a bug.** A token is only renewed once its remaining validity drops below +`REFRESH_MARGIN` (default 900 s = 15 min). A connection showing *24 min* remaining is correctly +left alone — renewing early would burn refresh-token rotations for nothing. + +The dashboard states this per connection, in the **Renewal diagnosis** column: + +> Outside the 15 min margin: renewal expected in ~9 min + +**When it *is* a problem:** the diagnosis column says something else. + +| Diagnosis | Meaning | Action | +| :--- | :--- | :--- | +| `No expiry recorded` | The connection has no readable `expiresAt`. | It will be renewed on the next sweep; if it persists, check the gateway wrote the field. | +| `Token expired` | Renewal is due but has not succeeded. | Open **Logs** on the scheduler card — the failing cycle carries the provider error. | +| `Local instance did not answer the model catalog` | The local Ollama/vLLM is down. | Check the instance and its `baseUrl`. | + +If you want renewal to happen sooner, raise the margin rather than shortening the interval: + +``` +REFRESH_MARGIN=1800 # renew during the last 30 minutes +``` + +--- + +## `BrokenPipeError: [Errno 32] Broken pipe` in `serve_healthz` + +``` +File "/app/src/omini_rtksync/web/server.py", line 116, in serve_healthz + self.wfile.write(b"OK") +BrokenPipeError: [Errno 32] Broken pipe +``` + +**Fixed.** Root cause was two compounding problems: + +1. The HTTP server was single-threaded despite the module promising multi-thread, so one slow + request blocked everything else. +2. `/healthz` made an outbound HTTP call of up to 3 s to the gateway on **every** probe. The + Docker health check (5 s timeout, every 15 s) gave up and closed the socket before the + response body was written, and `socketserver` printed the whole traceback. + +Now the server is a `ThreadingHTTPServer`, the gateway probe is cached for 30 s, and client +disconnects are swallowed instead of logged as failures. If you still see it, you are running an +image from before the fix — pull `ghcr.io/pathbit/ominirtksync:latest` again. + +--- + +## The local Ollama shows up but without its models + +The connection is classified as **Local** and probed on `/api/tags` and `/v1/models`. If the +model list is empty: + +- The connection has no `baseUrl` — the gateway stores it on the provider record; check it in the + OmniRoute UI. +- The container cannot reach the host instance. From inside the container, `localhost` is the + container, not your machine. Use `host.docker.internal` (Docker Desktop) or the host's LAN IP. +- The instance requires an API key the connection does not carry. + +A local instance that does not answer is marked `unreachable` and shows the **Unknown** badge — +deliberately, so a dead instance is not silently reported as healthy. + +--- + +## The dashboard shows stale data + +The page is rendered on the server and served with `Cache-Control: no-store`, so a reload always +re-reads the database. Use the **Refresh** button (it is a plain link to `/`). + +If the numbers still look wrong, the synchronizer may not be writing at all — check +`GET /healthz`: + +| Response | Meaning | +| :--- | :--- | +| `OK` | Database readable and gateway reachable. | +| `DATABASE_NOT_READY` | `DB_PATH` points at a file that does not exist. | +| `OMNIROUTE_SERVICE_UNREACHABLE` | `OMNIROUTE_URL` is wrong, or the gateway is down. | + +--- + +## I forgot the dashboard password + +Sign in with user `admin` and the **recovery hash** as the password. Find it with: + +```bash +docker logs ominirtksync 2>&1 | grep "Recovery hash" +# or, if the log file is mounted: +grep "Recovery hash" /app/data/logs/ominirtksync.log +``` + +If the log has already rotated past it, the value is on disk: + +```bash +docker exec ominirtksync cat /app/data/.dashboard_recovery +``` + +To pin your own instead of relying on the generated one, set `DASHBOARD_RECOVERY_HASH` and +restart. See [Authentication](Authentication). + +--- + +## Changing the password from the panel answers `409 Conflict` + +The service is in headless mode: `DASHBOARD_USER` and/or `DASHBOARD_PASSWORD` are set in the +environment, which makes them the source of truth. Change them in the environment and restart, or +comment both out to hand control back to the dashboard. + +--- + +## Log files are not being written + +The file log is best-effort — the synchronizer never refuses to start because of it. On startup +you will see: + +``` +[LOG] File log unavailable at /app/data/logs: [Errno 13] Permission denied +``` + +Fix the volume permissions, or point `LOG_DIR` somewhere writable. Events keep going to stdout +while `LOG_TO_STDOUT=1`. + +--- + +## Both synchronizers fight over the same port + +They listen on **9090 inside their own container** by design. Only the published host port +differs: `9091` for 9RTKSync, `9092` for OminiRTKSync. If you changed `WEB_PORT`, change it in +one container only — there is no reason for the internal ports to differ. + +--- + +## Connections keep getting skipped by the gateway + +Two separate causes, worth telling apart: + +- **Stale rate-limit lock.** The synchronizer removes expired `rateLimitedUntil` and + `modelLock_*` entries and resets `backoffLevel` on every sweep. Check the scheduler **Logs** + for a `Rate limit lock removed` line. +- **A gateway-side parsing bug.** Both upstream gateways had a bug where a credential expiry in + certain shapes silently disabled their own proactive refresh. See [Upstream Fixes](Upstream-Fixes). diff --git a/docs/wiki/Upstream-Fixes.md b/docs/wiki/Upstream-Fixes.md new file mode 100644 index 0000000..111c6bf --- /dev/null +++ b/docs/wiki/Upstream-Fixes.md @@ -0,0 +1,98 @@ +# Upstream Fixes + +Some of what this synchronizer works around are bugs in the gateways themselves. Where that is +the case, the fix belongs upstream — a workaround in a sidecar helps only the people running the +sidecar. + +This page tracks what was found and what was sent. + +--- + +## 9Router — numeric-epoch `expiresAt` silently disabled OAuth refresh + +**Upstream PR:** [decolua/9router#3997](https://github.com/decolua/9router/pull/3997) + +`parseTimeMs()` in `open-sse/services/oauthCredentialManager.js` accepted a `number` and anything +`Date` can parse — but a numeric epoch **in string form** fell through to the `Date` branch, +where it is an Invalid Date: + +```js +new Date("1789012345678").getTime() // NaN -> parseTimeMs returns null +``` + +A `null` expiry disables **both** proactive refresh paths: + +| Call site | Effect | +| :--- | :--- | +| `shouldRefreshCredentials()` | `expiresAtMs !== null` is false — the on-request refresh never fires. | +| `selectConnectionsNeedingRefresh()` | `if (expiresAtMs === null) continue;` — the background sweep skips the connection. | + +The connection then keeps an expired access token and 401s until the user re-authenticates by +hand. Same user-visible symptom as upstream issue #2546 ("session dies 40-45 min after login"), +reached through a different input shape. + +**Where the shape comes from:** the bulk-import routes persist the user-supplied value verbatim — +`grok-cli/bulk-import/route.js:77`, `codex/bulk-import/route.js:97`, +`kiro/import-cli-proxy/route.js:20` — while `lib/oauth/kiroExternalIdp.js` already normalizes to +ISO. The convention existed; the parser just did not accept what those routes could store. + +**Fix:** `parseTimeMs()` converts numeric strings using the same seconds/ms heuristic it already +applied to numbers, and is exported so `normalizeExpiresAt()` reuses it — an epoch already stored +self-heals to ISO on the next refresh. + +--- + +## OmniRoute — numeric epoch expiry broke the token health check + +**Upstream PR:** [diegosouzapw/OmniRoute#13444](https://github.com/diegosouzapw/OmniRoute/pull/13444) + +`provider_connections.expires_at` / `token_expires_at` are **TEXT** columns, so an epoch always +reads back as a string. `getEffectiveTokenExpiryMs()` went straight to `new Date()`: + +```ts +new Date("1789012345678").getTime() // NaN +new Date(1789012345).getTime() // 1970-01-21 — seconds read as milliseconds +``` + +The sibling helper right below it, `getCopilotTokenExpiryMs()`, already handled both numeric +shapes. The main connection path never got the same treatment. + +Two failure modes in `checkConnection()`: + +1. **Numeric string → never refreshed.** `NaN` → `0` → `hasKnownExpiry` false → `isAboutToExpire` + false. For a rotating provider (`codex`, `claude`, `kiro`, `openai`, …) `shouldRefreshByInterval` + is also false, so `if (!isAboutToExpire && !shouldRefreshByInterval) return;` returns early on + every sweep. The expiry-driven refresh the surrounding comment says it prefers is silently off. +2. **Epoch seconds as a number → a refresh loop.** Parsed as milliseconds it lands in 1970, so + `isAboutToExpire` is permanently true and *every* sweep refreshes the connection — burning + single-use refresh-token rotations. + +**Fix:** extract the numeric/string handling into one exported `parseTokenExpiryMs()` and route +both call sites through it. + +--- + +## What we fixed on our side + +This project was itself writing `expires_at` as a numeric epoch in text (`str(expires_at_ms)`) +and `test_status = 'ok'` — a value OmniRoute does not recognise as healthy. Both are fixed; it now +writes ISO-8601 and `'active'`, the gateway's native formats. + +That is the loop worth naming: the sidecar wrote a shape the gateway could not read, so the +gateway stopped refreshing, so the sidecar had to do all the refreshing. Fixing one side without +the other would have left it half-broken. + +--- + +## A note on the README claim + +An earlier version of the sibling project's README stated that 9Router writing `expiresAt` as an ISO +string "breaks internal numeric validations, producing false HTTP 503 errors". + +Reading the upstream source does not support that. 9Router consistently parses `expiresAt` with +`new Date(...)`, which handles ISO correctly, and no 503 path is tied to credential expiry. The +real defect is the opposite shape — a numeric epoch the parser rejects — which is what +[#3997](https://github.com/decolua/9router/pull/3997) fixes. + +The normalization these synchronizers perform is still useful: it is what keeps the stored value +in a shape every reader on both sides handles. diff --git a/docs/wiki/_Footer.md b/docs/wiki/_Footer.md new file mode 100644 index 0000000..262b7fa --- /dev/null +++ b/docs/wiki/_Footer.md @@ -0,0 +1 @@ +Generated from `docs/wiki/` — edits made directly in the wiki are overwritten on the next push to `master`. · [Pathbit](https://pathbit.co/) diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..dbfa1f0 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,15 @@ +### OminiRTKSync + +- [Home](Home) +- [Installation](Installation) +- [Configuration](Configuration) +- [Dashboard](Dashboard) +- [Authentication](Authentication) +- [Logging](Logging) +- [Architecture](Architecture) +- [Troubleshooting](Troubleshooting) +- [Upstream Fixes](Upstream-Fixes) + +--- + +Edited in [`docs/wiki/`](https://github.com/pathbit/OminiRTkSync/tree/master/docs/wiki) From 08bdc0115946ecc2942cae0e132fce8bcdc4b1fa Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 11:17:43 -0300 Subject: [PATCH 05/17] feat(providers): descobre os modelos da instancia local e detecta quando ela cai O LocalProvider so devolvia 'Conexao local operacional', sem nunca falar com a instancia. Um Ollama local fora do ar continuava aparecendo como saudavel, e os modelos que ele serve nunca chegavam ao painel. - can_handle passa a reconhecer ollama/vllm/lmstudio/localai/llamacpp e qualquer baseUrl apontando para o host. Uma instancia local costuma usar chave de fachada, e por isso caia na classificacao de provedor de nuvem. - discover_models consulta /api/tags e /v1/models e grava discoveredModels. - Sem resposta do catalogo a conexao vira 'unreachable' em vez de 'active'. - Erro de conexao encerra a sondagem no primeiro endpoint: tentar os tres com nada escutando so multiplica o timeout por 3 a cada varredura. Testes: 96 -> 106 (tests/test_local_provider.py e novo). --- src/omini_rtksync/providers.py | 95 ++++++++++++++++++++++++++++++++-- tests/test_local_provider.py | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 tests/test_local_provider.py diff --git a/src/omini_rtksync/providers.py b/src/omini_rtksync/providers.py index f2cdec7..c6de61e 100644 --- a/src/omini_rtksync/providers.py +++ b/src/omini_rtksync/providers.py @@ -3,6 +3,7 @@ import json import os import time +import urllib.error import urllib.parse import urllib.request from typing import Any, Dict, List, Optional, Tuple @@ -210,12 +211,98 @@ def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s return modified, res if modified else None, messages +# Catalog endpoints, in attempt order: Ollama-native and the OpenAI standard. +MODEL_CATALOG_PATHS = ("/api/tags", "/v1/models", "/models") +PROBE_TIMEOUT_SECONDS = 3.0 + +LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") +LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal") + + class LocalProvider: - """Monitor para conexões locais OpenAI-compatíveis (Ollama, vLLM) no OmniRoute.""" + """Health monitor for local OpenAI-compatible instances (Ollama, vLLM, LM Studio).""" + + @staticmethod + def _base_url(conn: Dict[str, Any]) -> str: + raw = conn.get("raw") or {} + return str(conn.get("baseUrl") or raw.get("base_url") or raw.get("baseUrl") or "") def can_handle(self, conn: Dict[str, Any]) -> bool: - p = conn.get("provider", "").lower() - return "ollama" in p or "openai-compatible" in p or not (conn.get("isOAuth") or conn.get("hasApiKey")) + provider = str(conn.get("provider", "")).lower() + if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): + return True + if any(host in self._base_url(conn) for host in LOCAL_HOSTS): + return True + return not (conn.get("isOAuth") or conn.get("hasApiKey")) + + def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], str]: + """Query the local instance catalog. Returns (models, error).""" + if not base_url: + return [], "baseUrl not declared on the connection" + + root = base_url.rstrip("/") + # An OpenAI-shaped baseUrl already ends in /v1; the root serves /api/tags. + origin = root[: -len("/v1")] if root.endswith("/v1") else root + last_error = "" + + for path in MODEL_CATALOG_PATHS: + target = f"{origin}{path}" if path.startswith("/api") else f"{root}{path}" + try: + req = urllib.request.Request( + target, headers={"User-Agent": "OminiRTKSync-LocalProbe/1.0"} + ) + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT_SECONDS) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + # The host answered, this path just is not the right one — keep trying. + last_error = str(e) + continue + except (urllib.error.URLError, OSError) as e: + # Nothing is listening: trying the remaining paths only multiplies the + # timeout (3 endpoints x 3s) on every sweep. Give up now. + return [], str(e) + except ValueError as e: + last_error = str(e) + continue + + models = self._extract_model_names(payload) + if models: + return models, "" + + return [], last_error or "no model returned by the local instance" + + @staticmethod + def _extract_model_names(payload: Any) -> List[str]: + """Extract model names from the Ollama (/api/tags) and OpenAI (/v1/models) shapes.""" + if not isinstance(payload, dict): + return [] + entries = payload.get("models") or payload.get("data") or [] + names = [] + for entry in entries: + if isinstance(entry, str): + names.append(entry) + elif isinstance(entry, dict): + name = entry.get("name") or entry.get("id") or entry.get("model") + if name: + names.append(str(name)) + return names def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: - return False, None, ["Conexão local operacional"] + models, probe_error = self.discover_models(self._base_url(conn), conn.get("apiKey") or "") + + if models: + return ( + True, + {"discoveredModels": models, "testStatus": "active"}, + [f"Local instance answered with {len(models)} model(s): {', '.join(models[:5])}"], + ) + + # With no catalog response the connection is not assumed healthy: this is + # exactly the "the local Ollama went down and nobody noticed" case. + return ( + True, + {"testStatus": "unreachable", "lastError": probe_error}, + [f"Local instance did not answer the model catalog: {probe_error}"], + ) diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py new file mode 100644 index 0000000..cd808ec --- /dev/null +++ b/tests/test_local_provider.py @@ -0,0 +1,94 @@ +"""Tests for local instance discovery (Ollama, vLLM, LM Studio).""" + +import unittest +from unittest import mock + +from omini_rtksync.providers import LocalProvider + + +class TestLocalProviderDetection(unittest.TestCase): + def setUp(self): + self.provider = LocalProvider() + + def test_recognises_local_provider_names(self): + for name in ("ollama", "openai-compatible-chat-ollama-local", "vllm-host", + "lmstudio", "localai", "llamacpp-server"): + with self.subTest(provider=name): + # A facade API key must not turn a local instance into a cloud provider. + self.assertTrue(self.provider.can_handle({"provider": name, "hasApiKey": True})) + + def test_recognises_a_local_base_url(self): + for url in ("http://localhost:11434/v1", "http://127.0.0.1:8000/v1", + "http://host.docker.internal:11434"): + with self.subTest(url=url): + self.assertTrue( + self.provider.can_handle({"provider": "custom", "hasApiKey": True, "baseUrl": url}) + ) + + def test_does_not_claim_cloud_providers(self): + self.assertFalse( + self.provider.can_handle( + {"provider": "groq", "hasApiKey": True, "baseUrl": "https://api.groq.com/openai/v1"} + ) + ) + + +class TestModelDiscovery(unittest.TestCase): + def setUp(self): + self.provider = LocalProvider() + + def test_parses_the_ollama_catalog_shape(self): + payload = {"models": [{"name": "llama3.2:3b"}, {"name": "qwen2.5-coder:7b"}]} + self.assertEqual( + LocalProvider._extract_model_names(payload), ["llama3.2:3b", "qwen2.5-coder:7b"] + ) + + def test_parses_the_openai_catalog_shape(self): + payload = {"data": [{"id": "gpt-oss:20b"}, {"id": "phi4"}]} + self.assertEqual(LocalProvider._extract_model_names(payload), ["gpt-oss:20b", "phi4"]) + + def test_ignores_unusable_payloads(self): + for payload in ([], None, {"models": []}, {"other": [1, 2]}, "text"): + with self.subTest(payload=payload): + self.assertEqual(LocalProvider._extract_model_names(payload), []) + + def test_missing_base_url_is_reported(self): + models, error = self.provider.discover_models("") + self.assertEqual(models, []) + self.assertIn("baseUrl", error) + + def test_unreachable_instance_stops_after_the_first_attempt(self): + """Trying all three endpoints against a dead host just triples the timeout.""" + with mock.patch("omini_rtksync.providers.urllib.request.urlopen", + side_effect=OSError("Connection refused")) as urlopen: + models, error = self.provider.discover_models("http://127.0.0.1:11434/v1") + self.assertEqual(models, []) + self.assertIn("Connection refused", error) + self.assertEqual(urlopen.call_count, 1) + + +class TestCheckAndRefresh(unittest.TestCase): + def setUp(self): + self.provider = LocalProvider() + self.conn = {"provider": "ollama-local", "baseUrl": "http://127.0.0.1:11434/v1"} + + def test_reachable_instance_records_its_models(self): + with mock.patch.object(LocalProvider, "discover_models", return_value=(["llama3.2:3b"], "")): + modified, data, messages = self.provider.check_and_refresh(self.conn) + self.assertTrue(modified) + self.assertEqual(data["discoveredModels"], ["llama3.2:3b"]) + # 'active' is the only value OmniRoute treats as healthy. + self.assertEqual(data["testStatus"], "active") + self.assertIn("1 model(s)", messages[0]) + + def test_unreachable_instance_is_not_assumed_healthy(self): + with mock.patch.object(LocalProvider, "discover_models", return_value=([], "Connection refused")): + modified, data, messages = self.provider.check_and_refresh(self.conn) + self.assertTrue(modified) + self.assertEqual(data["testStatus"], "unreachable") + self.assertEqual(data["lastError"], "Connection refused") + self.assertIn("did not answer", messages[0]) + + +if __name__ == "__main__": + unittest.main() From 1084aaba824b223d82fa759ea54bb707a15f4874 Mon Sep 17 00:00:00 2001 From: elielsousa-pathbit Date: Sat, 12 Sep 2026 11:47:04 -0300 Subject: [PATCH 06/17] fix: valida credenciais de verdade, corrige deteccao de instancia local e fecha CSRF Espelha no OminiRTKSync as mesmas correcoes do projeto irmao 9RTKSync. O painel declarava toda conexao "operacional e ativa" apenas por existir uma chave, sem nunca perguntar nada ao provedor: uma chave revogada seguia verde ate uma requisicao real falhar. Validacao viva (credential_check.py): - API keys: 401/403 = recusada, 429 = rate limited, qualquer outra resposta HTTP = credencial aceita (validamos a credencial, nao o modelo). - Tokens OAuth: consulta o tokeninfo do Google, que responde 400 quando o token morreu, em vez de inferir vida a partir da validade gravada. - Endpoints escolhidos por medicao: /api/v1/models do OpenRouter responde 200 sem credencial nenhuma e validaria qualquer lixo, entao usa-se /api/v1/key; o catalogo do Ollama Cloud e publico pelo mesmo motivo. - Desligada por padrao no construtor. O teste do engine e o modulo da CLI passam a desliga-la explicitamente: sem isso o ciclo chamava a API do provedor de verdade e a suite passava a depender da internet, que foi exatamente como a CI quebrou antes. Deteccao de instancia local: - baseUrl mora em providerSpecificData, nao na raiz. Lendo so a raiz, nenhuma instancia local exibia seus modelos. - Classificacao pelo endereco, nao pelo nome: "ollama" tambem e o nome do Ollama Cloud, que era tratado como local e sondado em /api/tags. - ConnectionRecord passa a expor access_token e refresh_token, que faltavam. Seguranca do painel: - POST de outra origem recusado: o Basic Auth e anexado pelo navegador mesmo em formulario de outro site, e urlencoded nao dispara preflight. - /api/status devolvia accessToken, refreshToken, apiKey e a linha bruta do banco; passa a projetar apenas os campos que a tela consome. Interface: Google Fonts, linha de diagnostico do gateway alinhada a direita como as demais e badges/traducoes (en/pt/es) para os estados novos. --- README.md | 14 +- src/omini_rtksync/cli.py | 6 +- src/omini_rtksync/config.py | 7 + src/omini_rtksync/credential_check.py | 262 +++++++++++++++++++++++++ src/omini_rtksync/i18n.py | 51 +++++ src/omini_rtksync/models.py | 59 ++++-- src/omini_rtksync/providers.py | 47 ++++- src/omini_rtksync/render.py | 20 ++ src/omini_rtksync/web.py | 43 ++++- tests/test_cli.py | 27 ++- tests/test_credential_check.py | 265 ++++++++++++++++++++++++++ tests/test_web_security.py | 156 +++++++++++++++ 12 files changed, 932 insertions(+), 25 deletions(-) create mode 100644 src/omini_rtksync/credential_check.py create mode 100644 tests/test_credential_check.py create mode 100644 tests/test_web_security.py diff --git a/README.md b/README.md index ce89f6b..9dc1cc6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ request; a push to `master` republishes the wiki automatically. * **Auto-Detecção de Bancos de Dados** * Detecção automática entre caminhos padrão do container (`/app/data/storage.sqlite`) e instalações locais (`~/.omniroute/data/storage.sqlite`). * **Dashboard Web Embutido** - * Painel de controle na porta `9191` para monitoramento do estado de cada conexão registrada e acionamento sob demanda de sincronização. + * Painel de controle na porta `9090` (publicada em `9092`) para monitoramento do estado de cada conexão registrada e acionamento sob demanda de sincronização. * **Isolamento Completo em Virtual Environment** * Execução segura e isolada em ambiente virtual Python tanto em containers Docker (`/opt/venv`) quanto em instalações de desenvolvimento local (`.venv`). @@ -72,10 +72,10 @@ services: ominirtksync: image: ghcr.io/pathbit/ominirtksync:latest - container_name: router-sync + container_name: ominirtksync restart: unless-stopped ports: - - "127.0.0.1:9191:9191" + - "127.0.0.1:9092:9090" volumes: - omniroute_data:/app/data - ${HOME}:/root/host:ro @@ -86,13 +86,13 @@ services: - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - - WEB_PORT=${WEB_PORT:-9191} + - WEB_PORT=${WEB_PORT:-9090} - DASHBOARD_USER=${DASHBOARD_USER:-admin} - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-pathbit} depends_on: - omniroute healthcheck: - test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9191/healthz', timeout=3)"] + test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] interval: 15s timeout: 5s retries: 3 @@ -156,7 +156,7 @@ OminiRTKSync --daemon --db-path /caminho/para/storage.sqlite | `SYNC_INTERVAL` | `300` | Intervalo em segundos entre varreduras no modo daemon e cron | | `REFRESH_MARGIN` | `900` | Margem prévia em segundos para renovação de tokens | | `ENABLE_WEB_DASHBOARD` | `1` | Ativa o dashboard web embutido (`1` para sim, `0` para não) | -| `WEB_PORT` | `9191` | Porta do dashboard web HTTP | +| `WEB_PORT` | `9090` | Porta do dashboard web HTTP | | `WEB_HOST` | `0.0.0.0` | Interface de rede para o servidor web | | `DASHBOARD_USER` | `admin` | Usuário de autenticação HTTP Basic Auth | | `DASHBOARD_PASSWORD` | `pathbit` | Senha padrão inicial de autenticação HTTP Basic Auth | @@ -168,7 +168,7 @@ OminiRTKSync --daemon --db-path /caminho/para/storage.sqlite Com `ENABLE_WEB_DASHBOARD=1`, acesse no navegador: -👉 **http://localhost:9191** +👉 **http://localhost:9092** Recursos do painel: * Monitoramento de todas as conexões cadastradas no OmniRoute. diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 53daba3..6df541e 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -31,7 +31,11 @@ def __init__(self, settings: Settings): ) self.google_provider = GoogleProvider(credential_paths=settings.credential_paths, discovery=self.discovery) self.oauth_provider = GenericOAuthProvider(discovery=self.discovery) - self.api_provider = ApiKeyProvider(discovery=self.discovery) + self.api_provider = ApiKeyProvider( + discovery=self.discovery, + validate_credentials=settings.validate_credentials, + validation_timeout=settings.validation_timeout, + ) self.local_provider = LocalProvider() def sync_all(self): diff --git a/src/omini_rtksync/config.py b/src/omini_rtksync/config.py index e272e23..fd32ef5 100644 --- a/src/omini_rtksync/config.py +++ b/src/omini_rtksync/config.py @@ -50,6 +50,10 @@ class Settings: dashboard_password: str = "pathbit" cron_interval: int = 300 cron_enabled: bool = True + # Validação viva das credenciais: pergunta ao provedor se a chave ainda é + # aceita, em vez de pintar a linha de verde só porque existe uma chave. + validate_credentials: bool = True + validation_timeout: float = 8.0 # Quando DASHBOARD_USER/DASHBOARD_PASSWORD vêm explicitamente do ambiente, elas # passam a ser a fonte de verdade e o arquivo salvo pela tela é ignorado. É o # que permite operar 100% headless (Docker, Kubernetes, CI) sem nunca abrir o @@ -199,5 +203,8 @@ def from_env(cls, env_file: str = ".env") -> "Settings": dashboard_password=d_pass, cron_interval=cron_int, cron_enabled=cron_on, + validate_credentials=os.environ.get("CREDENTIAL_CHECK_ENABLED", "1") + not in ("0", "false", "no"), + validation_timeout=float(os.environ.get("CREDENTIAL_CHECK_TIMEOUT", "8")), dashboard_auth_from_env=auth_from_env, ) diff --git a/src/omini_rtksync/credential_check.py b/src/omini_rtksync/credential_check.py new file mode 100644 index 0000000..09160cc --- /dev/null +++ b/src/omini_rtksync/credential_check.py @@ -0,0 +1,262 @@ +"""Live validation of the credentials stored in the gateway. + +Until now a connection was reported healthy just for carrying an API key -- the +panel painted every row green without ever asking the provider whether the key +still worked. This module actually calls each provider and reports what came +back. + +What counts as a valid credential: the provider accepted the authentication. +A 404 for a missing model or a 400 for an empty body still means the key was +accepted, so only 401/403 (and the idiomatic 400 that Google AI Studio returns +for a bad key) are treated as a rejection. + +Endpoint choices are deliberate, and were measured rather than assumed: + +- OpenRouter's ``/api/v1/models`` answers 200 with no credential at all, so it + cannot validate anything. ``/api/v1/key`` answers 401. +- Ollama Cloud's catalog is public for the same reason; a chat completion is + the cheapest call that requires the key. +- Google AI Studio authenticates with the ``x-goog-api-key`` header and answers + 400 -- not 401 -- for a bad key. +- OAuth access tokens are checked with Google's ``tokeninfo``, which answers 400 + once the token dies. +""" + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Dict, Optional + +DEFAULT_TIMEOUT_SECONDS = 8.0 +USER_AGENT = "OminiRTKSync-CredentialCheck/1.0" + +# States a probe can conclude. "not_checked" is the absence of a probe. +STATE_VALID = "valid" +STATE_INVALID = "invalid" +STATE_RATE_LIMITED = "rate_limited" +STATE_UNREACHABLE = "unreachable" +STATE_UNSUPPORTED = "unsupported" + + +@dataclass +class CheckResult: + """Outcome of a single credential probe.""" + + state: str + detail: str = "" + http_status: int = 0 + latency_ms: int = 0 + checked_at: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "credentialState": self.state, + "credentialDetail": self.detail, + "credentialHttpStatus": self.http_status, + "credentialLatencyMs": self.latency_ms, + "credentialCheckedAt": self.checked_at, + } + + +@dataclass +class ProbeSpec: + """How to ask one provider whether a credential is still accepted.""" + + url: str + auth_header: str = "Authorization" + auth_template: str = "Bearer {key}" + method: str = "GET" + body: Optional[bytes] = None + content_type: str = "" + # Statuses that mean "credential rejected" beyond the usual 401/403. + invalid_statuses: tuple = () + + +# Matched by substring against the provider name, longest marker first so +# "openai-compatible-chat-ollama-local" never matches the bare "ollama" entry. +API_KEY_PROBES: Dict[str, ProbeSpec] = { + "groq": ProbeSpec("https://api.groq.com/openai/v1/models"), + "mistral": ProbeSpec("https://api.mistral.ai/v1/models"), + "openai": ProbeSpec("https://api.openai.com/v1/models"), + "anthropic": ProbeSpec( + "https://api.anthropic.com/v1/models", + auth_header="x-api-key", + auth_template="{key}", + ), + "openrouter": ProbeSpec("https://openrouter.ai/api/v1/key"), + "gemini": ProbeSpec( + "https://generativelanguage.googleapis.com/v1beta/models", + auth_header="x-goog-api-key", + auth_template="{key}", + invalid_statuses=(400,), + ), + "ollama": ProbeSpec( + "https://ollama.com/v1/chat/completions", + method="POST", + body=json.dumps({"model": "gpt-oss:20b", "messages": [], "max_tokens": 1}).encode(), + content_type="application/json", + ), +} + +GOOGLE_TOKENINFO = "https://oauth2.googleapis.com/tokeninfo" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _classify(status: int, spec_invalid: tuple = ()) -> str: + if status in (401, 403) or status in spec_invalid: + return STATE_INVALID + if status == 429: + return STATE_RATE_LIMITED + return STATE_VALID + + +def _execute( + request: urllib.request.Request, + timeout: float, + opener: Optional[Callable] = None, + spec_invalid: tuple = (), +) -> CheckResult: + """Run one probe and turn whatever happened into a CheckResult.""" + send = opener or urllib.request.urlopen + started = time.time() + try: + with send(request, timeout=timeout) as resp: + status = getattr(resp, "status", 200) + return CheckResult( + state=_classify(status, spec_invalid), + detail=f"HTTP {status}", + http_status=status, + latency_ms=int((time.time() - started) * 1000), + checked_at=_now_iso(), + ) + except urllib.error.HTTPError as e: + # The provider answered -- that answer is exactly the signal we want. + state = _classify(e.code, spec_invalid) + return CheckResult( + state=state, + detail=f"HTTP {e.code}", + http_status=e.code, + latency_ms=int((time.time() - started) * 1000), + checked_at=_now_iso(), + ) + except Exception as e: + # No answer at all: the credential is unproven, not proven bad. + return CheckResult( + state=STATE_UNREACHABLE, + detail=str(e)[:200], + latency_ms=int((time.time() - started) * 1000), + checked_at=_now_iso(), + ) + + +# Markers that describe a self-hosted endpoint and must never resolve to a +# vendor URL. "openai-compatible-chat-ollama-local" contains both "openai" and +# "ollama"; without this guard it would be probed against api.openai.com. +SELF_HOSTED_MARKERS = ("openai-compatible", "-local", "localai") + + +def select_probe(provider: str) -> Optional[ProbeSpec]: + """Pick the probe for a provider name, preferring the most specific marker. + + Returns None when the credential belongs to a self-hosted endpoint or to a + provider with no known probe; the caller then falls back to base_url. + """ + name = (provider or "").lower() + if any(marker in name for marker in SELF_HOSTED_MARKERS): + return None + + matches = [marker for marker in API_KEY_PROBES if marker in name] + if not matches: + return None + # Sort by length then alphabetically so the choice never depends on dict order. + return API_KEY_PROBES[sorted(matches, key=lambda m: (-len(m), m))[0]] + + +def check_api_key( + provider: str, + api_key: str, + base_url: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable] = None, +) -> CheckResult: + """Ask the provider whether this API key is still accepted.""" + if not api_key: + return CheckResult(state=STATE_UNSUPPORTED, detail="No API key", checked_at=_now_iso()) + + spec = select_probe(provider) + if spec is None: + if not base_url: + return CheckResult( + state=STATE_UNSUPPORTED, + detail=f"No known probe for '{provider}'", + checked_at=_now_iso(), + ) + # Unknown provider with a declared address: the OpenAI-compatible + # catalog is the convention every one of them follows. + spec = ProbeSpec(base_url.rstrip("/") + "/models") + + request = urllib.request.Request(spec.url, method=spec.method, data=spec.body) + request.add_header(spec.auth_header, spec.auth_template.format(key=api_key)) + request.add_header("User-Agent", USER_AGENT) + if spec.content_type: + request.add_header("Content-Type", spec.content_type) + + return _execute(request, timeout, opener, spec.invalid_statuses) + + +def check_oauth_token( + access_token: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable] = None, +) -> CheckResult: + """Ask Google whether this access token is still alive. + + Answers the question directly instead of inferring liveness from the stored + expiry, which is what the panel used to do. + """ + if not access_token: + return CheckResult(state=STATE_UNSUPPORTED, detail="No access token", checked_at=_now_iso()) + + query = urllib.parse.urlencode({"access_token": access_token}) + request = urllib.request.Request(f"{GOOGLE_TOKENINFO}?{query}") + request.add_header("User-Agent", USER_AGENT) + # tokeninfo reports a dead token as 400, not 401. + return _execute(request, timeout, opener, spec_invalid=(400,)) + + +def check_connection( + conn: Any, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable] = None, +) -> CheckResult: + """Validate whichever credential the connection actually carries.""" + if getattr(conn, "is_local", False): + # Local instances are proven by their model catalog, not by a cloud API. + return CheckResult( + state=STATE_UNSUPPORTED, + detail="Local instance: validated by model discovery", + checked_at=_now_iso(), + ) + + if getattr(conn, "is_oauth", False) and getattr(conn, "access_token", None): + return check_oauth_token(conn.access_token, timeout=timeout, opener=opener) + + if getattr(conn, "has_api_key", False): + return check_api_key( + conn.provider, + conn.api_key or "", + base_url=getattr(conn, "base_url", None), + timeout=timeout, + opener=opener, + ) + + return CheckResult( + state=STATE_UNSUPPORTED, detail="No credential to validate", checked_at=_now_iso() + ) diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 1d21956..82e33f7 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -80,6 +80,23 @@ "health.rate_limited": "Rate limited", "health.no_expiration": "No expiry", "health.unknown": "Unknown", + "health.invalid": "Rejected", + "health.unreachable": "Unreachable", + "health.not_checked": "Not checked", + "gateway.diagnostics": "Diagnostics", + "gateway.diag_ok": "Gateway and SQLite database fully operational", + "gateway.diag_db_failed": "Gateway online, database unreadable", + "gateway.diag_gateway_failed": "Gateway unreachable", + "credential.title": "Credential", + "credential.checked_at": "Checked at", + "credential.never": "Never validated", + "action.validate": "Validate credentials", + "password.policy": "At least 6 characters, with uppercase, lowercase, a number and a special character.", + "password.too_short": "Password must have at least 6 characters.", + "password.needs_upper": "Password must contain an uppercase letter.", + "password.needs_lower": "Password must contain a lowercase letter.", + "password.needs_digit": "Password must contain a number.", + "password.needs_special": "Password must contain a special character.", "duration.unlimited": "Unlimited / N/A", "duration.expired": "Expired", "reason.api_key": "Static key: never expires, nothing to renew", @@ -159,6 +176,23 @@ "health.rate_limited": "Rate limit", "health.no_expiration": "Sem expiração", "health.unknown": "Desconhecido", + "health.invalid": "Recusada", + "health.unreachable": "Inacessível", + "health.not_checked": "Não verificada", + "gateway.diagnostics": "Diagnóstico", + "gateway.diag_ok": "Gateway e banco SQLite totalmente operacionais", + "gateway.diag_db_failed": "Gateway online, banco ilegível", + "gateway.diag_gateway_failed": "Gateway inacessível", + "credential.title": "Credencial", + "credential.checked_at": "Verificada em", + "credential.never": "Nunca validada", + "action.validate": "Validar credenciais", + "password.policy": "Mínimo de 6 caracteres, com maiúscula, minúscula, número e caractere especial.", + "password.too_short": "A senha precisa ter ao menos 6 caracteres.", + "password.needs_upper": "A senha precisa conter uma letra maiúscula.", + "password.needs_lower": "A senha precisa conter uma letra minúscula.", + "password.needs_digit": "A senha precisa conter um número.", + "password.needs_special": "A senha precisa conter um caractere especial.", "duration.unlimited": "Ilimitado / N/A", "duration.expired": "Expirado", "reason.api_key": "Chave estática: não expira, nada a renovar", @@ -238,6 +272,23 @@ "health.rate_limited": "Límite de tasa", "health.no_expiration": "Sin expiración", "health.unknown": "Desconocido", + "health.invalid": "Rechazada", + "health.unreachable": "Inaccesible", + "health.not_checked": "Sin verificar", + "gateway.diagnostics": "Diagnóstico", + "gateway.diag_ok": "Gateway y base SQLite totalmente operativos", + "gateway.diag_db_failed": "Gateway en línea, base ilegible", + "gateway.diag_gateway_failed": "Gateway inaccesible", + "credential.title": "Credencial", + "credential.checked_at": "Verificada el", + "credential.never": "Nunca validada", + "action.validate": "Validar credenciales", + "password.policy": "Mínimo de 6 caracteres, con mayúscula, minúscula, número y carácter especial.", + "password.too_short": "La contraseña necesita al menos 6 caracteres.", + "password.needs_upper": "La contraseña necesita una letra mayúscula.", + "password.needs_lower": "La contraseña necesita una letra minúscula.", + "password.needs_digit": "La contraseña necesita un número.", + "password.needs_special": "La contraseña necesita un carácter especial.", "duration.unlimited": "Ilimitado / N/D", "duration.expired": "Expirado", "reason.api_key": "Clave estática: no expira, nada que renovar", diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py index a3628cb..aa7f5d9 100644 --- a/src/omini_rtksync/models.py +++ b/src/omini_rtksync/models.py @@ -11,9 +11,11 @@ from .normalizer import parse_expiry_to_ms -# Provider names that identify a local / OpenAI-compatible instance. +# Provider names that suggest a local instance. A marker alone is not proof: +# "ollama" is also the name of Ollama Cloud, a hosted service that must never +# be probed on /api/tags. LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") -LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal") +LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal", ".local") # Threshold below which a token counts as "expiring soon" (15 min). EXPIRING_SOON_SECONDS = 900 @@ -50,21 +52,39 @@ def has_api_key(self) -> bool: def api_key(self) -> Optional[str]: return self.data.get("apiKey") + @property + def access_token(self) -> Optional[str]: + return self.data.get("accessToken") + + @property + def refresh_token(self) -> Optional[str]: + return self.data.get("refreshToken") + @property def is_local(self) -> bool: - """Whether the connection points at a local instance. + """Whether the connection really points at an instance on this machine. - A local instance usually needs a facade API key, so checking has_api_key - alone would classify it as a cloud provider. + Classification is driven by the address, not by the provider name. Only + when no address is declared does a marker like "openai-compatible" -- + which has no hosted counterpart -- stand on its own. """ - provider = self.provider.lower() - if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): - return True - base_url = str(self.base_url or "") - return any(host in base_url for host in LOCAL_HOSTS) + base_url = str(self.base_url or "").lower() + if base_url: + return any(host in base_url for host in LOCAL_HOSTS) + return "openai-compatible" in self.provider.lower() @property def base_url(self) -> Optional[str]: + """Endereço do provedor, onde quer que o gateway o tenha guardado. + + O 9Router aninha em providerSpecificData; lendo só a raiz, instância + local nenhuma exibia seus modelos. + """ + specific = self.data.get("providerSpecificData") + if isinstance(specific, dict): + nested = specific.get("baseUrl") or specific.get("baseURL") + if nested: + return nested raw = self.data.get("raw") or {} return ( self.data.get("baseUrl") @@ -94,9 +114,23 @@ def remaining_seconds(self) -> Optional[int]: return None return int((exp - int(time.time() * 1000)) / 1000) + @property + def credential_state(self) -> Optional[str]: + """Resultado da última validação viva da credencial, quando houve uma.""" + state = self.data.get("credentialState") + return str(state) if state else None + @property def health_status(self) -> str: - """Semantic classification of the connection state.""" + """Semantic classification of the connection state. + + Uma validação viva vence tudo: chave que o provedor recusa está + quebrada, não importa o que o gateway tenha carimbado por último. + """ + probed = self.credential_state + if probed in ("invalid", "rate_limited", "unreachable"): + return probed + if self.is_local: # unreachable is written when the model catalog does not answer. return "unknown" if self.data.get("testStatus") == "unreachable" else "active" @@ -114,7 +148,8 @@ def health_status(self) -> str: if self.has_api_key: if self.data.get("rateLimitedUntil"): return "rate_limited" - return "active" + # Nunca sondada: dizer isso, em vez de alegar saúde que ninguém verificou. + return "active" if probed == "valid" else "not_checked" # OmniRoute writes "active"; 9Router writes "ok". Both mean healthy. return "active" if self.data.get("testStatus") in ("active", "ok") else "unknown" diff --git a/src/omini_rtksync/providers.py b/src/omini_rtksync/providers.py index c6de61e..158fcc4 100644 --- a/src/omini_rtksync/providers.py +++ b/src/omini_rtksync/providers.py @@ -8,6 +8,16 @@ import urllib.request from typing import Any, Dict, List, Optional, Tuple +from .credential_check import ( + DEFAULT_TIMEOUT_SECONDS, + STATE_INVALID, + STATE_RATE_LIMITED, + STATE_UNREACHABLE, + STATE_VALID, + check_connection, +) +from .models import ConnectionRecord + class GoogleProvider: """Renovador OAuth para contas Google (Antigravity / Gemini CLI) no OmniRoute.""" @@ -184,8 +194,18 @@ def check_and_refresh( class ApiKeyProvider: """Gerenciador e sanitizador para conexões de API Key no OmniRoute.""" - def __init__(self, discovery: Optional[Any] = None): + def __init__( + self, + discovery: Optional[Any] = None, + validate_credentials: bool = False, + validation_timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Any] = None, + ): self.discovery = discovery + # Desligado por padrão: montar o provider não pode gerar tráfego de saída. + self.validate_credentials = validate_credentials + self.validation_timeout = validation_timeout + self.opener = opener def can_handle(self, conn: Dict[str, Any]) -> bool: return bool(conn.get("hasApiKey")) @@ -205,8 +225,31 @@ def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s src = local.get("source_path", "host") messages.append(f"Chave de API sincronizada a partir do host ({src})") + # 2. Pergunta ao provedor se a chave ainda é aceita. Antes daqui a conexão + # era declarada "operacional e ativa" sem nenhuma verificação. + if self.validate_credentials: + record = ConnectionRecord.from_row(res) + result = check_connection( + record, timeout=self.validation_timeout, opener=self.opener + ) + res.update(result.to_dict()) + modified = True + + if result.state == STATE_VALID: + res["testStatus"] = "active" + messages.append(f"Chave aceita pelo provedor ({result.detail})") + elif result.state == STATE_INVALID: + res["testStatus"] = "invalid" + messages.append(f"Chave RECUSADA pelo provedor ({result.detail})") + elif result.state == STATE_RATE_LIMITED: + messages.append(f"Provedor aplicou rate limit na validação ({result.detail})") + elif result.state == STATE_UNREACHABLE: + messages.append(f"Provedor inacessível, chave não verificada: {result.detail}") + else: + messages.append(result.detail or "Credencial não verificável") + if not messages: - messages.append("Chave de API operacional e ativa") + messages.append("Chave de API inalterada") return modified, res if modified else None, messages diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index c0486fa..9175428 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -20,6 +20,13 @@ FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" JQUERY_JS = "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" +# Tipografia: Google Fonts, com pilha de sistema como reserva se o CDN cair. +GOOGLE_FONTS = ( + "https://fonts.googleapis.com/css2?" + "family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" +) +FONT_STACK = "'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" +MONO_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace" # Estado semântico -> (classe do badge, ícone) HEALTH_PRESENTATION = { @@ -29,6 +36,10 @@ "rate_limited": ("text-bg-warning", "bi-pause-circle-fill"), "no_expiration": ("text-bg-secondary", "bi-infinity"), "unknown": ("text-bg-secondary", "bi-question-circle-fill"), + # Estados vindos da validação viva da credencial. + "invalid": ("text-bg-danger", "bi-shield-exclamation"), + "unreachable": ("text-bg-warning", "bi-plug"), + "not_checked": ("text-bg-secondary", "bi-dash-circle"), } @@ -364,6 +375,13 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str: online = bool(gateway.get("online")) tone = "text-success" if online else "text-danger" + db_ok = bool(gateway.get("dbSummary")) + if online and db_ok: + diagnosis = translate("gateway.diag_ok", lang) + elif online: + diagnosis = translate("gateway.diag_db_failed", lang) + else: + diagnosis = translate("gateway.diag_gateway_failed", lang) icon = "bi-plug-fill" if online else "bi-plug" label = ( f'ONLINE (HTTP {esc(gateway.get("statusCode", "—"))})' @@ -398,6 +416,8 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str
    {esc(gateway.get("dbSummary") or "—")}
    +
    {esc(translate("gateway.diagnostics", lang))}
    +
    {esc(diagnosis)}
    """ diff --git a/src/omini_rtksync/web.py b/src/omini_rtksync/web.py index 4c26142..16052ca 100644 --- a/src/omini_rtksync/web.py +++ b/src/omini_rtksync/web.py @@ -104,10 +104,35 @@ def do_GET(self): else: self.send_error(HTTPStatus.NOT_FOUND) + def is_same_origin_request(self) -> bool: + """Rejeita POST disparado por outro site. + + O Basic Auth é anexado automaticamente pelo navegador mesmo em um POST + vindo de outra origem, e um formulário urlencoded não dispara preflight. + Sem esta checagem, uma página maliciosa aberta na mesma máquina poderia + trocar a senha do painel. Não se usa Referer porque a própria página é + servida com Referrer-Policy: no-referrer. + """ + fetch_site = self.headers.get("Sec-Fetch-Site", "") + if fetch_site: + # "none" é a navegação digitada na barra de endereços. + return fetch_site in ("same-origin", "none") + + origin = self.headers.get("Origin", "") + if origin: + return urlparse(origin).netloc == self.headers.get("Host", "") + + # Cliente que não é navegador (curl, script): não há sessão a sequestrar. + return True + def do_POST(self): if not self.require_auth(): return + if not self.is_same_origin_request(): + self.send_error(HTTPStatus.FORBIDDEN, "Cross-origin request rejected") + return + length = int(self.headers.get("Content-Length", 0)) raw_body = self.rfile.read(length) if length > 0 else b"{}" @@ -204,7 +229,23 @@ def serve_status(self): "currentUser": cur_user, "isDefaultPassword": is_default, "cron": cron_info, - "connections": conns, + # Projeção explícita: get_all_connections devolve accessToken, + # refreshToken, apiKey e a linha bruta do banco. Nada disso pode + # sair pela API — só os campos que o painel realmente consome. + "connections": [ + { + "id": c.id, + "provider": c.provider, + "name": c.name, + "isOAuth": c.is_oauth, + "hasApiKey": c.has_api_key, + "isLocal": c.is_local, + "expiresAtMs": c.expires_at_ms, + "remainingSeconds": c.remaining_seconds, + "healthStatus": c.health_status, + } + for c in (ConnectionRecord.from_row(row) for row in conns) + ], "combos": combos, } body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") diff --git a/tests/test_cli.py b/tests/test_cli.py index f4056f8..415529f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,21 @@ from omini_rtksync.config import Settings +def setUpModule(): + """Nenhum teste deste modulo pode sair para a internet. + + main() e print_status() montam Settings a partir do ambiente, onde a + validacao viva de credenciais vem ligada por padrao. + """ + os.environ["CREDENTIAL_CHECK_ENABLED"] = "0" + + +def tearDownModule(): + os.environ.pop("CREDENTIAL_CHECK_ENABLED", None) + + + + class TestOminiCLI(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() @@ -43,14 +58,22 @@ def tearDown(self): self.temp_dir.cleanup() def test_sync_engine_once(self): - settings = Settings(db_path=self.db_path, sync_interval=300, enable_web=False) + # validate_credentials desligado de proposito: sem isso o ciclo chama a + # API do provedor de verdade e o teste passa a depender da internet -- + # foi assim que a CI quebrou antes. + settings = Settings( + db_path=self.db_path, + sync_interval=300, + enable_web=False, + validate_credentials=False, + ) engine = OmniSyncEngine(settings) res = engine.sync_all() self.assertTrue(res["success"]) self.assertEqual(res["total"], 1) def test_print_status(self): - settings = Settings(db_path=self.db_path, enable_web=False) + settings = Settings(db_path=self.db_path, enable_web=False, validate_credentials=False) # Deve executar sem levantar exceção print_status(settings) diff --git a/tests/test_credential_check.py b/tests/test_credential_check.py new file mode 100644 index 0000000..2942feb --- /dev/null +++ b/tests/test_credential_check.py @@ -0,0 +1,265 @@ +"""Testes da validacao viva de credenciais. + +Nenhum teste aqui toca a rede: todo probe recebe um opener falso. Foi um teste +que dependia de um servico real que derrubou a CI antes. +""" + +import json +import unittest +import urllib.error +from typing import Optional + +from omini_rtksync import credential_check as cc +from omini_rtksync.models import ConnectionRecord +from omini_rtksync.providers import ApiKeyProvider + + +class FakeResponse: + def __init__(self, status: int): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def opener_returning(status: int): + """Opener que responde com o status pedido e registra a requisicao.""" + captured = {} + + def _opener(request, timeout=None): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["headers"] = {k.lower(): v for k, v in request.header_items()} + if status >= 400: + raise urllib.error.HTTPError(request.full_url, status, "err", {}, None) + return FakeResponse(status) + + _opener.captured = captured + return _opener + + +def opener_raising(exc: Exception): + def _opener(request, timeout=None): + raise exc + + return _opener + + +class TestClassification(unittest.TestCase): + def test_200_is_valid(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertEqual(r.http_status, 200) + + def test_401_is_invalid(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(401)) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_403_is_invalid(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(403)) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_429_is_rate_limited(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(429)) + self.assertEqual(r.state, cc.STATE_RATE_LIMITED) + + def test_404_still_means_the_key_was_accepted(self): + """Validamos a credencial, nao o modelo: 404 de modelo nao invalida a chave.""" + r = cc.check_api_key("groq", "k", opener=opener_returning(404)) + self.assertEqual(r.state, cc.STATE_VALID) + + def test_network_failure_is_unreachable_not_invalid(self): + """Sem resposta a credencial fica nao comprovada, nunca comprovadamente ruim.""" + r = cc.check_api_key("groq", "k", opener=opener_raising(OSError("connection refused"))) + self.assertEqual(r.state, cc.STATE_UNREACHABLE) + + def test_missing_key_is_unsupported(self): + r = cc.check_api_key("groq", "") + self.assertEqual(r.state, cc.STATE_UNSUPPORTED) + + +class TestProbeSelection(unittest.TestCase): + def test_openrouter_uses_the_key_endpoint_not_the_public_catalog(self): + """/api/v1/models responde 200 sem credencial nenhuma: validaria qualquer lixo.""" + op = opener_returning(200) + cc.check_api_key("openrouter", "k", opener=op) + self.assertEqual(op.captured["url"], "https://openrouter.ai/api/v1/key") + + def test_gemini_authenticates_by_header_and_treats_400_as_invalid(self): + op = opener_returning(400) + r = cc.check_api_key("gemini", "k", opener=op) + self.assertIn("x-goog-api-key", op.captured["headers"]) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_gemini_400_is_invalid_but_groq_400_is_not(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(400)) + self.assertEqual(r.state, cc.STATE_VALID) + + def test_ollama_cloud_posts_because_the_catalog_is_public(self): + op = opener_returning(200) + cc.check_api_key("ollama", "k", opener=op) + self.assertEqual(op.captured["method"], "POST") + self.assertIn("ollama.com", op.captured["url"]) + + def test_self_hosted_name_never_resolves_to_a_vendor_url(self): + """'openai-compatible-chat-ollama-local' casa com 'openai' e com 'ollama'; + sem barreira, a chave de fachada de um Ollama local iria para a api.openai.com.""" + self.assertIsNone(cc.select_probe("openai-compatible-chat-ollama-local")) + self.assertIsNone(cc.select_probe("localai")) + + def test_marker_choice_is_deterministic(self): + self.assertEqual(cc.select_probe("groq-cloud").url, "https://api.groq.com/openai/v1/models") + self.assertEqual(cc.select_probe("ollama").url, "https://ollama.com/v1/chat/completions") + + def test_self_hosted_falls_back_to_its_own_address(self): + op = opener_returning(200) + r = cc.check_api_key( + "openai-compatible-chat-ollama-local", + "k", + base_url="http://host.docker.internal:11434/v1", + opener=op, + ) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertEqual(op.captured["url"], "http://host.docker.internal:11434/v1/models") + + def test_unknown_provider_without_address_is_unsupported(self): + r = cc.check_api_key("provedor-desconhecido", "k", opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_UNSUPPORTED) + + def test_unknown_provider_with_address_uses_the_openai_convention(self): + op = opener_returning(200) + r = cc.check_api_key( + "provedor-desconhecido", "k", base_url="https://api.exemplo.com/v1", opener=op + ) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertEqual(op.captured["url"], "https://api.exemplo.com/v1/models") + + +class TestOAuthProbe(unittest.TestCase): + def test_live_token_is_valid(self): + r = cc.check_oauth_token("ya29.token", opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_VALID) + + def test_dead_token_answers_400_and_is_invalid(self): + """tokeninfo devolve 400, nao 401, para token morto.""" + r = cc.check_oauth_token("ya29.morto", opener=opener_returning(400)) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_token_travels_in_the_query(self): + op = opener_returning(200) + cc.check_oauth_token("ya29.abc", opener=op) + self.assertIn("access_token=ya29.abc", op.captured["url"]) + + +class TestConnectionDispatch(unittest.TestCase): + def build(self, provider, payload): + row = {"id": "c1", "provider": provider, "name": provider} + row.update(payload) + row["isOAuth"] = bool(payload.get("accessToken") or payload.get("refreshToken")) + row["hasApiKey"] = bool(payload.get("apiKey")) + return ConnectionRecord.from_row(row) + + def test_oauth_connection_checks_the_token(self): + conn = self.build("antigravity", {"accessToken": "ya29.x", "refreshToken": "r"}) + op = opener_returning(200) + r = cc.check_connection(conn, opener=op) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertIn("tokeninfo", op.captured["url"]) + + def test_api_key_connection_checks_the_key(self): + conn = self.build("groq", {"apiKey": "gsk_x"}) + op = opener_returning(200) + cc.check_connection(conn, opener=op) + self.assertIn("groq.com", op.captured["url"]) + + def test_local_instance_is_not_sent_to_a_cloud_api(self): + conn = self.build( + "openai-compatible-chat-ollama-local", + {"apiKey": "x", "providerSpecificData": {"baseUrl": "http://host.docker.internal:11434/v1"}}, + ) + r = cc.check_connection(conn, opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_UNSUPPORTED) + self.assertIn("Local instance", r.detail) + + +class TestApiKeyProviderIntegration(unittest.TestCase): + def build(self, provider, payload): + row = {"id": "c1", "provider": provider, "name": provider} + row.update(payload) + row["isOAuth"] = bool(payload.get("accessToken") or payload.get("refreshToken")) + row["hasApiKey"] = bool(payload.get("apiKey")) + return ConnectionRecord.from_row(row) + + def test_validation_is_off_unless_asked(self): + """Protege a CI: montar o provider nao pode gerar trafego de saida.""" + provider = ApiKeyProvider() + self.assertFalse(provider.validate_credentials) + + def row(self, provider, payload): + row = {"id": "c1", "provider": provider, "name": provider} + row.update(payload) + row["hasApiKey"] = bool(payload.get("apiKey")) + return row + + def test_rejected_key_is_never_stamped_as_active(self): + """O bug original: a conexao era declarada ativa sem perguntar a ninguem.""" + provider = ApiKeyProvider(validate_credentials=True, opener=opener_returning(401)) + _, data, msgs = provider.check_and_refresh( + self.row("groq", {"apiKey": "revogada", "testStatus": "active"}) + ) + self.assertEqual(data["testStatus"], "invalid") + self.assertEqual(data["credentialState"], cc.STATE_INVALID) + self.assertTrue(any("RECUSADA" in m for m in msgs)) + + def test_accepted_key_is_stamped_active_with_evidence(self): + provider = ApiKeyProvider(validate_credentials=True, opener=opener_returning(200)) + _, data, _ = provider.check_and_refresh(self.row("groq", {"apiKey": "boa"})) + self.assertEqual(data["testStatus"], "active") + self.assertEqual(data["credentialState"], cc.STATE_VALID) + self.assertTrue(data["credentialCheckedAt"]) + + def test_unreachable_provider_does_not_mark_the_key_invalid(self): + provider = ApiKeyProvider( + validate_credentials=True, opener=opener_raising(OSError("timeout")) + ) + _, data, _ = provider.check_and_refresh( + self.row("groq", {"apiKey": "boa", "testStatus": "active"}) + ) + self.assertNotEqual(data.get("testStatus"), "invalid") + self.assertEqual(data["credentialState"], cc.STATE_UNREACHABLE) + + +class TestHealthStatusUsesTheProbe(unittest.TestCase): + def build(self, provider, payload): + row = {"id": "c1", "provider": provider, "name": provider} + row.update(payload) + row["isOAuth"] = bool(payload.get("accessToken") or payload.get("refreshToken")) + row["hasApiKey"] = bool(payload.get("apiKey")) + return ConnectionRecord.from_row(row) + + def test_key_never_probed_is_not_claimed_healthy(self): + conn = self.build("groq", {"apiKey": "x"}) + self.assertEqual(conn.health_status, "not_checked") + + def test_probed_valid_key_is_active(self): + conn = self.build("groq", {"apiKey": "x", "credentialState": "valid"}) + self.assertEqual(conn.health_status, "active") + + def test_probed_invalid_key_overrides_everything(self): + conn = self.build("groq", {"apiKey": "x", "testStatus": "ok", "credentialState": "invalid"}) + self.assertEqual(conn.health_status, "invalid") + + def test_invalid_probe_beats_a_healthy_oauth_expiry(self): + conn = self.build( + "antigravity", + {"accessToken": "a", "refreshToken": "r", "expiresAt": 9_999_999_999_999, + "credentialState": "invalid"}, + ) + self.assertEqual(conn.health_status, "invalid") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_security.py b/tests/test_web_security.py new file mode 100644 index 0000000..5dffa90 --- /dev/null +++ b/tests/test_web_security.py @@ -0,0 +1,156 @@ +"""Testes de superficie de ataque do painel: CSRF nas acoes e vazamento de segredo na API.""" + +import base64 +import json +import os +import sqlite3 +import tempfile +import time +import unittest +import urllib.error +import urllib.request + +from omini_rtksync.config import Settings +from omini_rtksync import web as web_server + +PORT = 19394 +BASE = f"http://127.0.0.1:{PORT}" + +ACCESS_TOKEN = "ya29.SEGREDO-DE-ACESSO-NAO-PODE-VAZAR" +REFRESH_TOKEN = "1//SEGREDO-DE-REFRESH-NAO-PODE-VAZAR" +API_KEY = "sk-SEGREDO-DE-CHAVE-NAO-PODE-VAZAR" + + +class TestWebSecurity(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp_dir = tempfile.TemporaryDirectory() + cls.db_path = os.path.join(cls.tmp_dir.name, "data.sqlite") + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE provider_connections (" + "id TEXT PRIMARY KEY, provider TEXT, name TEXT, access_token TEXT, " + "refresh_token TEXT, api_key TEXT, expires_at TEXT, test_status TEXT)" + ) + conn.execute("CREATE TABLE combos (id TEXT PRIMARY KEY, name TEXT, kind TEXT, models TEXT)") + conn.execute( + "INSERT INTO provider_connections VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + "conn-1", + "google-antigravity", + "Antigravity", + ACCESS_TOKEN, + REFRESH_TOKEN, + API_KEY, + "2026-12-31T00:00:00.000Z", + "active", + ), + ) + + cls.settings = Settings( + db_path=cls.db_path, + web_host="127.0.0.1", + web_port=PORT, + dashboard_user="admin", + dashboard_password="senha-de-teste", + ) + cls.server = web_server.start_omini_web( + "127.0.0.1", PORT, cls.db_path, omniroute_url="", settings=cls.settings + ) + time.sleep(0.3) + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.tmp_dir.cleanup() + + def auth_header(self): + raw = base64.b64encode(b"admin:senha-de-teste").decode() + return {"Authorization": f"Basic {raw}"} + + def post(self, path, headers=None, body=b"idioma=pt"): + req = urllib.request.Request(f"{BASE}{path}", data=body, method="POST") + for key, value in self.auth_header().items(): + req.add_header(key, value) + for key, value in (headers or {}).items(): + req.add_header(key, value) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status + except urllib.error.HTTPError as e: + return e.code + + # --- CSRF --------------------------------------------------------------- + + def test_cross_site_post_is_rejected(self): + """O Basic Auth vai junto num POST de outro site; sem esta barreira daria para + trocar a senha do painel a partir de uma pagina maliciosa.""" + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "cross-site"}) + self.assertEqual(status, 403) + + def test_same_site_post_is_rejected(self): + """Subdominio tambem e outra origem.""" + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "same-site"}) + self.assertEqual(status, 403) + + def test_cross_origin_by_origin_header_is_rejected(self): + """Navegador antigo, sem Sec-Fetch-Site: cai na comparacao de Origin com Host.""" + status = self.post("/acoes/idioma", {"Origin": "http://site-malicioso.example"}) + self.assertEqual(status, 403) + + def test_same_origin_post_is_accepted(self): + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "same-origin"}) + self.assertNotEqual(status, 403) + + def test_direct_navigation_is_accepted(self): + """Sec-Fetch-Site: none e a navegacao digitada na barra de enderecos.""" + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "none"}) + self.assertNotEqual(status, 403) + + def test_matching_origin_is_accepted(self): + status = self.post("/acoes/idioma", {"Origin": BASE}) + self.assertNotEqual(status, 403) + + def test_non_browser_client_is_accepted(self): + """curl e scripts nao mandam nenhum dos dois cabecalhos e nao tem sessao a sequestrar.""" + status = self.post("/acoes/idioma") + self.assertNotEqual(status, 403) + + def test_csrf_guard_also_covers_the_json_endpoints(self): + status = self.post("/api/change-password", {"Sec-Fetch-Site": "cross-site"}, b"{}") + self.assertEqual(status, 403) + + # --- Vazamento de segredo ---------------------------------------------- + + def test_api_status_never_returns_credentials(self): + req = urllib.request.Request(f"{BASE}/api/status") + for key, value in self.auth_header().items(): + req.add_header(key, value) + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8") + + for secret in (ACCESS_TOKEN, REFRESH_TOKEN, API_KEY): + self.assertNotIn(secret, body) + + payload = json.loads(body) + connection = payload["connections"][0] + self.assertEqual(connection["id"], "conn-1") + self.assertTrue(connection["isOAuth"]) + # A linha bruta do banco carrega os tokens e nao pode ser serializada. + for forbidden in ("accessToken", "refreshToken", "apiKey", "raw", "data"): + self.assertNotIn(forbidden, connection) + + def test_dashboard_html_never_returns_credentials(self): + req = urllib.request.Request(BASE + "/") + for key, value in self.auth_header().items(): + req.add_header(key, value) + with urllib.request.urlopen(req, timeout=5) as resp: + page = resp.read().decode("utf-8") + + for secret in (ACCESS_TOKEN, REFRESH_TOKEN, API_KEY): + self.assertNotIn(secret, page) + + +if __name__ == "__main__": + unittest.main() From 5ff7cc8767e59ead87728bf905dda92c1b1d6541 Mon Sep 17 00:00:00 2001 From: elielsousa-pathbit Date: Sat, 12 Sep 2026 11:54:39 -0300 Subject: [PATCH 07/17] feat: senha com politica de forca gravada como hash no sqlite e refresh que releia tudo O aviso de seguranca ficava na tela comparando a senha ativa com o texto "pathbit". Agora ele depende do que interessa: existir ou nao uma senha definida pelo usuario no banco do painel. Definida a senha, o aviso some. Senha: - Passa a viver no SQLite do sincronizador como hash PBKDF2-SHA256 com sal, em vez de texto puro no .dashboard_auth.json, que e apagado na troca. - Politica obrigatoria: minimo de 6 caracteres com maiuscula, minuscula, numero e caractere especial, validada no formulario, na acao da tela e no endpoint JSON. A recusa lista de uma vez todas as regras violadas, no idioma escolhido, em vez de revelar a politica a cada tentativa. - Quem ja tinha senha no arquivo antigo continua entrando: password_matches reconhece o texto puro herdado ate a proxima troca. Refresh: - O botao Atualizar virou uma acao que zera o cache da sondagem ao gateway antes de remontar a pagina; como link simples, o painel podia repetir por ate 30s o estado anterior a acao recem-disparada. A sincronizacao manual passa a invalidar o mesmo cache. Tipografia do Google Fonts, que estava declarada mas nunca inserida no head. --- .env.example | 6 ++ docs/wiki/Configuration.md | 2 + src/omini_rtksync/auth.py | 97 +++++++++++++++++++++++++++- src/omini_rtksync/config.py | 67 +++++++++++++++----- src/omini_rtksync/i18n.py | 3 + src/omini_rtksync/render.py | 18 ++++-- src/omini_rtksync/web.py | 37 +++++++++-- tests/test_auth_recovery.py | 6 +- tests/test_config_env.py | 9 ++- tests/test_password_policy.py | 116 ++++++++++++++++++++++++++++++++++ tests/test_web_render.py | 2 +- 11 files changed, 329 insertions(+), 34 deletions(-) create mode 100644 tests/test_password_policy.py diff --git a/.env.example b/.env.example index d7911c4..1454ed2 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,9 @@ INITIAL_PASSWORD=PathbitDevs2026! JWT_SECRET=omniroute-jwt-secret-key-pathbit REQUIRE_API_KEY=false REQUIRE_LOGIN=false + +# --- Validacao viva de credenciais ----------------------------------------- +# Pergunta a cada provedor se a chave/token ainda e aceito, em vez de assumir +# que uma conexao esta saudavel so por carregar uma credencial. +CREDENTIAL_CHECK_ENABLED=1 +CREDENTIAL_CHECK_TIMEOUT=8 diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index ddfdc08..fec1ebe 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -40,6 +40,8 @@ out and produce `BrokenPipeError` in the logs. | `REFRESH_MARGIN` | `900` | Seconds of remaining validity below which a token is renewed. | | `CRON_INTERVAL` | inherits `SYNC_INTERVAL` | Dedicated interval for the scheduler, when you want it to differ from the sync pass. | | `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Run now** button, or `POST /api/sync`). | +| `CREDENTIAL_CHECK_ENABLED` | `1` | Asks each provider whether the stored credential is still accepted. `0` turns the live check off and the panel falls back to reporting `Not checked`. | +| `CREDENTIAL_CHECK_TIMEOUT` | `8` | Seconds allowed per credential probe. | > **A token is only renewed inside the margin.** With the defaults, a token with 24 minutes left > is *not* renewed, because 24 min > 15 min. That is correct behavior, not a failure — the diff --git a/src/omini_rtksync/auth.py b/src/omini_rtksync/auth.py index fa0e8d3..78bacba 100644 --- a/src/omini_rtksync/auth.py +++ b/src/omini_rtksync/auth.py @@ -29,6 +29,33 @@ RECOVERY_FILE_NAME = ".dashboard_recovery" RECOVERY_USER = "admin" +# Politica de senha do painel. Exigida sempre que a senha for definida ou +# trocada pela tela; o ambiente headless nao passa por aqui porque quem opera +# DASHBOARD_PASSWORD ja controla o segredo por fora. +MIN_PASSWORD_LENGTH = 6 +SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.<>?/\\|`~\"'" + + +def validate_password_strength(password: str) -> list: + """Devolve as chaves de traducao das regras que a senha nao cumpre. + + Lista vazia significa senha aceita. Devolver todas as falhas de uma vez + evita o vaivem de corrigir um requisito por tentativa. + """ + problems = [] + if len(password or "") < MIN_PASSWORD_LENGTH: + problems.append("password.too_short") + if not any(c.isupper() for c in password or ""): + problems.append("password.needs_upper") + if not any(c.islower() for c in password or ""): + problems.append("password.needs_lower") + if not any(c.isdigit() for c in password or ""): + problems.append("password.needs_digit") + if not any(c in SPECIAL_CHARACTERS for c in password or ""): + problems.append("password.needs_special") + return problems + + def constant_time_equals(a: str, b: str) -> bool: """Compara duas strings em tempo constante.""" @@ -40,6 +67,45 @@ def derive_recovery_hash(secret: str) -> str: return hashlib.sha256(str(secret).encode("utf-8")).hexdigest() +# --- Armazenamento da senha ------------------------------------------------- +# +# A senha do painel passa a viver no SQLite do sincronizador como hash PBKDF2, +# nunca em texto puro. O formato carrega os proprios parametros, entao aumentar +# o custo no futuro nao invalida o que ja esta gravado. +PBKDF2_ITERATIONS = 240_000 +PBKDF2_PREFIX = "pbkdf2_sha256" + + +def hash_password(password: str, *, salt: Optional[bytes] = None, + iterations: int = PBKDF2_ITERATIONS) -> str: + """Deriva o hash armazenavel de uma senha.""" + salt = salt or secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iterations) + return f"{PBKDF2_PREFIX}${iterations}${salt.hex()}${digest.hex()}" + + +def password_matches(stored: str, candidate: str) -> bool: + """Compara uma senha com o valor gravado. + + Aceita tambem o texto puro herdado do .dashboard_auth.json antigo, para que + uma instalacao existente continue entrando enquanto nao troca a senha. + """ + if not stored: + return False + + if not stored.startswith(PBKDF2_PREFIX + "$"): + return constant_time_equals(stored, candidate) + + try: + _, iterations, salt_hex, digest_hex = stored.split("$", 3) + expected = hashlib.pbkdf2_hmac( + "sha256", (candidate or "").encode("utf-8"), bytes.fromhex(salt_hex), int(iterations) + ) + except (ValueError, TypeError): + return False + return hmac.compare_digest(expected.hex(), digest_hex) + + def read_stored_credentials(auth_file: str) -> Optional[Tuple[str, str]]: """Lê as credenciais gravadas pela tela. Devolve None quando ainda não houve troca.""" if not auth_file or not os.path.exists(auth_file): @@ -56,6 +122,31 @@ def read_stored_credentials(auth_file: str) -> Optional[Tuple[str, str]]: return None +# Chaves de credencial no banco de preferencias do painel. +AUTH_USER_KEY = "auth.user" +AUTH_PASSWORD_KEY = "auth.password_hash" + + +def read_db_credentials(prefs_path: str) -> Optional[Tuple[str, str]]: + """Credenciais gravadas no SQLite do painel, ou None se nunca definidas.""" + from .prefs import get_preference + + user = get_preference(prefs_path, AUTH_USER_KEY) + stored = get_preference(prefs_path, AUTH_PASSWORD_KEY) + if user and stored: + return user, stored + return None + + +def write_db_credentials(prefs_path: str, user: str, password: str) -> bool: + """Grava usuario e hash da senha no SQLite do painel.""" + from .prefs import set_preference + + ok_user = set_preference(prefs_path, AUTH_USER_KEY, user) + ok_pass = set_preference(prefs_path, AUTH_PASSWORD_KEY, hash_password(password)) + return bool(ok_user and ok_pass) + + def resolve_recovery_hash(recovery_file: str) -> str: """Obtém o hash de recuperação: ambiente primeiro, senão o gerado/salvo localmente.""" from_env = os.environ.get("DASHBOARD_RECOVERY_HASH", "").strip() @@ -115,9 +206,11 @@ def verify_credentials( if stored is not None: # 1. Já houve troca de senha: só as credenciais salvas valem. + # password_matches entende tanto o hash PBKDF2 gravado no SQLite quanto + # o texto puro herdado do arquivo antigo. stored_user, stored_password = stored - return constant_time_equals(user, stored_user) and constant_time_equals( - password, stored_password + return constant_time_equals(user, stored_user) and password_matches( + stored_password, password ) # 2. Primeiro acesso: valem as credenciais de fábrica. diff --git a/src/omini_rtksync/config.py b/src/omini_rtksync/config.py index fd32ef5..2483faf 100644 --- a/src/omini_rtksync/config.py +++ b/src/omini_rtksync/config.py @@ -8,6 +8,9 @@ RECOVERY_FILE_NAME, ensure_recovery_hash, read_stored_credentials, + read_db_credentials, + write_db_credentials, + validate_password_strength, resolve_recovery_hash, verify_credentials, ) @@ -76,7 +79,11 @@ def get_stored_credentials(self) -> Optional[Tuple[str, str]]: """Credenciais gravadas pela tela, ou None quando o ambiente é autoritativo.""" if self.dashboard_auth_from_env: return None - return read_stored_credentials(self.get_auth_file_path()) + # O SQLite do painel e a fonte de verdade; o .dashboard_auth.json so + # existe para nao trancar quem ja tinha senha antes desta mudanca. + return read_db_credentials(self.get_prefs_path()) or read_stored_credentials( + self.get_auth_file_path() + ) def verify_credentials(self, user: str, password: str) -> bool: """Valida um par usuário/senha, incluindo a credencial de recuperação.""" @@ -89,6 +96,18 @@ def verify_credentials(self, user: str, password: str) -> bool: recovery_hash=self.get_recovery_hash(), ) + def get_prefs_path(self) -> str: + """Banco SQLite do painel, onde vivem preferencias e credenciais.""" + from .prefs import resolve_prefs_path + + return resolve_prefs_path(os.path.dirname(self.get_auth_file_path())) + + def has_stored_password(self) -> bool: + """Se ja existe senha definida pelo usuario no banco do painel.""" + if self.dashboard_auth_from_env: + return True + return read_db_credentials(self.get_prefs_path()) is not None + def get_auth_file_path(self) -> str: base_dir = os.environ.get("DATA_DIR", "") if not base_dir and self.db_path: @@ -118,27 +137,45 @@ def get_auth_credentials(self) -> tuple[str, str]: return self.dashboard_user, self.dashboard_password def is_default_password(self) -> bool: - _, p = self.get_auth_credentials() - return p == "pathbit" + """Se o painel ainda roda sem senha própria. + + O aviso de segurança depende disto: ele some assim que existe uma senha + gravada no SQLite, e não quando o texto deixa de ser "pathbit". + """ + return not self.has_stored_password() + + def check_password_strength(self, new_pass: str) -> list: + """Chaves de tradução das regras de senha que o valor não cumpre.""" + return validate_password_strength(new_pass) def update_auth_credentials(self, user: str, new_pass: str) -> bool: - # Em modo headless o ambiente é imutável pela tela — gravar o arquivo aqui - # criaria um estado fantasma que get_auth_credentials nunca leria. + """Grava as credenciais do painel no SQLite, como hash. + + Recusa senha fraca: a política de força é obrigatória. Em modo headless + o ambiente é imutável pela tela, e gravar aqui criaria estado fantasma + que get_auth_credentials nunca leria. + """ if self.dashboard_auth_from_env: return False - auth_file = self.get_auth_file_path() - try: - import json - payload = {"user": user.strip() or "admin", "password": new_pass.strip()} - with open(auth_file, "w", encoding="utf-8") as f: - json.dump(payload, f) - self.dashboard_user = payload["user"] - self.dashboard_password = payload["password"] - return True - except Exception: + new_pass = (new_pass or "").strip() + if validate_password_strength(new_pass): return False + final_user = (user or "").strip() or "admin" + if not write_db_credentials(self.get_prefs_path(), final_user, new_pass): + return False + + self.dashboard_user = final_user + self.dashboard_password = new_pass + # O arquivo em texto puro perde a razão de existir assim que a senha + # passa a viver no banco. + try: + os.remove(self.get_auth_file_path()) + except OSError: + pass + return True + @classmethod def from_env(cls, env_file: str = ".env") -> "Settings": load_dotenv(env_file) diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 82e33f7..642b9a9 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -91,6 +91,7 @@ "credential.checked_at": "Checked at", "credential.never": "Never validated", "action.validate": "Validate credentials", + "action.refreshed": "Page reloaded with fresh data.", "password.policy": "At least 6 characters, with uppercase, lowercase, a number and a special character.", "password.too_short": "Password must have at least 6 characters.", "password.needs_upper": "Password must contain an uppercase letter.", @@ -187,6 +188,7 @@ "credential.checked_at": "Verificada em", "credential.never": "Nunca validada", "action.validate": "Validar credenciais", + "action.refreshed": "Página recarregada com dados atualizados.", "password.policy": "Mínimo de 6 caracteres, com maiúscula, minúscula, número e caractere especial.", "password.too_short": "A senha precisa ter ao menos 6 caracteres.", "password.needs_upper": "A senha precisa conter uma letra maiúscula.", @@ -283,6 +285,7 @@ "credential.checked_at": "Verificada el", "credential.never": "Nunca validada", "action.validate": "Validar credenciales", + "action.refreshed": "Página recargada con datos actualizados.", "password.policy": "Mínimo de 6 caracteres, con mayúscula, minúscula, número y carácter especial.", "password.too_short": "La contraseña necesita al menos 6 caracteres.", "password.needs_upper": "La contraseña necesita una letra mayúscula.", diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 9175428..d45e1be 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -484,8 +484,10 @@ def render_dashboard(
    -
    {esc(translate("auth.min_chars", lang))}
    + minlength="6" autocomplete="new-password" required + pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{{6,}}" + title="{esc(translate("password.policy", lang))}"> +
    {esc(translate("password.policy", lang))}