diff --git a/.env.example b/.env.example index a992a77..fec1813 100644 --- a/.env.example +++ b/.env.example @@ -1,64 +1,116 @@ # ============================================================================== # 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 +# Diretorio base de tudo que este container escreve: o banco de preferencias do +# painel, a credencial de recuperacao e o diretorio de log. Por padrao e o +# diretorio do DB_PATH. Defina quando o banco do gateway estiver em um lugar +# onde o sincronizador nao deve escrever. +# DATA_DIR=/app/data + +# Cliente OAuth do Google usado para renovar credenciais Antigravity / Gemini +# CLI. Deixe AMBOS vazios para que o sincronizador os leia do arquivo de +# credencial que encontra no host. Preencha apenas em implantacao isolada, sem +# credencial do host montada. Sao segredos: o lugar deles e o seu .env, que +# nunca e versionado, jamais este arquivo de exemplo. +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + # ------------------------------------------------------------------------------ -# 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 +# DASHBOARD_PASSWORD= # vazio: usa a credencial de recuperacao do primeiro boot + +# 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= + +# ------------------------------------------------------------------------------ +# Log persistente em arquivo +# ------------------------------------------------------------------------------ +LOG_DIR=/app/data/logs +LOG_RETENTION_DAYS=30 +LOG_LEVEL=INFO +LOG_TO_STDOUT=1 # ------------------------------------------------------------------------------ -# 5. OmniRoute Stack Integration (Docker Compose) +# 5. Integracao com a Stack OmniRoute (Docker Compose) # ------------------------------------------------------------------------------ -# Initial password and JWT secret for OmniRoute -INITIAL_PASSWORD=PathbitDevs2026! -JWT_SECRET=omniroute-jwt-secret-key-pathbit +# Senha inicial e segredo JWT do OmniRoute. +# Ambos sao OBRIGATORIOS e vem vazios de proposito: um valor publicado em +# arquivo de exemplo e uma credencial publica, e vira a senha real de toda +# implantacao que copiou o arquivo. O `docker compose up` recusa subir +# enquanto nao forem preenchidos. +# INITIAL_PASSWORD -> no minimo 12 caracteres, gerados, nao digitados +# JWT_SECRET -> openssl rand -hex 32 +INITIAL_PASSWORD= +JWT_SECRET= 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/.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/.github/workflows/cleanup-packages.yml b/.github/workflows/cleanup-packages.yml index bf9c8ed..ce46b40 100644 --- a/.github/workflows/cleanup-packages.yml +++ b/.github/workflows/cleanup-packages.yml @@ -1,29 +1,87 @@ -name: Purge Packages +name: Package Retention + +# Mantem o registro enxuto sem nunca derrubar o que esta em uso. +# +# Historico: a primeira versao deste arquivo nao era limpeza. Com +# min-versions-to-keep: 0 e delete-only-untagged-versions: false ela apagava +# TODAS as versoes e em seguida removia o proprio package via API. Quem +# estivesse puxando ghcr.io/pathbit/ominirtksyncatest ficava sem imagem. +# +# A segunda versao corrigia isso, mas usava actions/delete-package-versions, +# que trata cada manifesto como uma versao independente. O build e multi-arch +# (linux/amd64 + linux/arm64): o buildx publica um manifest list com a tag e um +# manifesto SEM TAG por plataforma. Descartar "versoes sem tag" apaga +# justamente as camadas que a tag referencia, e o pull passa a falhar com +# "manifest unknown" mesmo com a tag intacta no registro. +# +# Por isso a limpeza aqui usa uma action que resolve o manifest list antes de +# apagar: um manifesto sem tag so e descartado se nenhuma tag preservada +# apontar para ele. on: workflow_dispatch: + inputs: + keep: + description: "Quantas versoes marcadas manter" + required: false + default: "3" + dry-run: + description: "Simular: lista o que seria apagado sem apagar" + required: false + default: "false" + schedule: + # Semanal, domingo 04:00 UTC: o acumulo vem dos builds, nao do relogio. + - cron: "0 4 * * 0" + workflow_run: + workflows: ["Release and Docker Package"] + types: [completed] permissions: packages: write +concurrency: + group: package-retention-ominirtksync + cancel-in-progress: false + jobs: - purge: + retention: + name: Apply retention policy runs-on: ubuntu-latest + # Depois de um release, so faz sentido limpar se o release funcionou. + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.conclusion == 'success' steps: - - name: Purge package versions - uses: actions/delete-package-versions@v5 + - name: Apply retention policy + uses: dataaxiom/ghcr-cleanup-action@v1.2.2 with: - package-name: 'ominirtksync' - package-type: 'container' - min-versions-to-keep: 0 - delete-only-untagged-versions: 'false' - continue-on-error: true + token: ${{ secrets.GITHUB_TOKEN }} + owner: ${{ github.repository_owner }} + packages: ominirtksync + # Guarda as N imagens marcadas mais recentes, com as camadas de + # plataforma de cada uma. 'latest' fica de fora da contagem por + # seguranca, ainda que por construcao ela aponte para a mais nova. + keep-n-tagged: ${{ github.event.inputs.keep || '3' }} + exclude-tags: latest + # Apaga apenas manifestos orfaos de verdade: os que sobraram de + # builds ja aposentados e nao pertencem a nenhuma tag preservada. + delete-untagged: true + # Restos de execucoes anteriores: manifest list cujas camadas de + # plataforma ja nao existem (nao ha o que puxar delas). + delete-ghost-images: true + delete-partial-images: true + delete-orphaned-images: true + # Confere no registro se cada digest referenciado existe mesmo, e + # registra o resultado no log da execucao. + validate: true + dry-run: ${{ github.event.inputs.dry-run || 'false' }} - - name: Force delete package via GitHub API + - name: Report what survived + if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - curl -X DELETE \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $GH_TOKEN" \ - https://api.github.com/orgs/pathbit/packages/container/ominirtksync || true + echo "### Versoes mantidas em ominirtksync" >> "$GITHUB_STEP_SUMMARY" + gh api "/orgs/${{ github.repository_owner }}/packages/container/ominirtksync/versions" \ + --jq '.[] | "- \(.name[0:19]) tags: \(.metadata.container.tags | join(", "))"' \ + >> "$GITHUB_STEP_SUMMARY" || echo "- (nao foi possivel listar)" >> "$GITHUB_STEP_SUMMARY" 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/.gitignore b/.gitignore index 77b10a0..4b900f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1218,3 +1218,21 @@ temp/ ### NOT IGNORE !.vscode/ + +# --- Artefatos de execucao do RTKSync (acrescentado ao final; o conteudo acima +# --- permanece exatamente como estava) --- +# O SQLite e criado no startup do proprio projeto: e estado de execucao, nunca +# fonte. Versiona-lo publicaria conexoes, tokens e chaves de quem rodou. +*.sqlite +*.sqlite3 +*.sqlite-journal +*.sqlite-wal +*.sqlite-shm +# Credenciais do painel: o hash de recuperacao e a senha em texto herdada. +.dashboard_recovery +.dashboard_auth.json +# Log persistente: pode conter endereco, nome de conexao e mensagem de erro. +*.log +logs/ +# Clones do upstream para leitura (9router, OmniRoute, litellm). +tmp/ 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..54b27a3 100644 --- a/README.md +++ b/README.md @@ -6,38 +6,48 @@ [![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). + + +## 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. --- -## 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 `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`). --- -## 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: @@ -53,8 +63,8 @@ services: - HOSTNAME=0.0.0.0 - NEXT_PUBLIC_BASE_URL=http://localhost:20128 - NODE_ENV=production - - INITIAL_PASSWORD=${INITIAL_PASSWORD:-PathbitDevs2026!} - - JWT_SECRET=${JWT_SECRET:-omniroute-jwt-secret-key-pathbit} + - INITIAL_PASSWORD=${INITIAL_PASSWORD:?defina no .env} + - JWT_SECRET=${JWT_SECRET:?openssl rand -hex 32} - REQUIRE_API_KEY=false - REQUIRE_LOGIN=false volumes: @@ -62,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 @@ -76,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} + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} 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 @@ -94,19 +104,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 +124,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` | `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` | *(vazio)* | Senha do painel. Vazia, o primeiro acesso usa a credencial de recuperação gerada no primeiro boot. | +| `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** +👉 **http://localhost:9092** -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. +## Testes Unitários -### Option 1. Via Docker Container (Zero Host Installation) - -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..45062a0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -13,22 +13,43 @@ services: - HOSTNAME=0.0.0.0 - NEXT_PUBLIC_BASE_URL=http://localhost:20128 - NODE_ENV=production - - INITIAL_PASSWORD=${INITIAL_PASSWORD:-PathbitDevs2026!} - - JWT_SECRET=${JWT_SECRET:-omniroute-jwt-secret-key-pathbit} + # Sem valor de fallback: um default publicado em arquivo de exemplo vira + # a senha real de toda implantacao que so copiou e colou. O compose + # recusa subir ate que as duas variaveis estejam definidas no .env. + - INITIAL_PASSWORD=${INITIAL_PASSWORD:?defina INITIAL_PASSWORD no .env} + - JWT_SECRET=${JWT_SECRET:?defina JWT_SECRET no .env (openssl rand -hex 32)} - REQUIRE_API_KEY=false + # REQUIRE_LOGIN=false so e aceitavel porque a porta acima esta presa em + # 127.0.0.1. Ao expor 20128 na rede, mude para true. - REQUIRE_LOGIN=false volumes: - omniroute_data:/app/data + # Sem este healthcheck o `condition: service_healthy` la embaixo nao tem o + # que esperar, e o compose recusa subir a stack inteira dizendo que a + # dependencia nao declara healthcheck. + healthcheck: + test: + - CMD-SHELL + - >- + node -e "require('http').get('http://127.0.0.1:20128/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))" + interval: 15s + timeout: 5s + retries: 10 + # O primeiro boot roda as migracoes e cria o storage.sqlite; da folga. + start_period: 40s 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 +57,36 @@ 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} + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} + # 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 + # Validacao viva de credenciais: pergunta ao provedor se a chave ainda e + # aceita, em vez de assumir que uma conexao esta saudavel por carregar uma + # credencial. Anunciadas no .env.example e lidas pelo Settings, faltava + # repassa-las ao container. + - CREDENTIAL_CHECK_ENABLED=${CREDENTIAL_CHECK_ENABLED:-1} + - CREDENTIAL_CHECK_TIMEOUT=${CREDENTIAL_CHECK_TIMEOUT:-8} + - LOG_DIR=${LOG_DIR:-/app/data/logs} + - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} + - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - - omniroute + omniroute: + # Esperar o gateway ficar saudavel, e nao apenas iniciado: o OmniRoute + # cria o storage.sqlite durante o proprio boot, e subir antes disso faz + # o primeiro ciclo encontrar o banco ausente. + condition: service_healthy + 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 index db5ca61..72ecca5 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,12 +1,79 @@ -name: ominirtksync-tests +# Stack de teste isolada: gateway real + este sincronizador. +# +# Nome de projeto, nomes de container e portas proprios, para conviver com +# qualquer outra stack na mesma maquina. Tudo preso em 127.0.0.1 -- o painel le +# credencial, e nada disso vai para a rede. +# +# docker compose -f docker-compose.test.yml up -d +# docker compose -f docker-compose.test.yml down -v +# +# Nao depende de nenhuma stack de artigo: e uma stack do proprio repositorio. + +name: ominirtksync-test services: - test: - image: python:3.14-alpine - container_name: ominirtksync-test-runner + omniroute: + image: diegosouzapw/omniroute:latest + container_name: ominirtksync-test-gateway + restart: unless-stopped + ports: + - "127.0.0.1:19129:20128" + environment: + - DATA_DIR=/app/data + - PORT=20128 + - HOSTNAME=0.0.0.0 + - NODE_ENV=production + # Obrigatorias e sem valor padrao: um default publicado em arquivo de + # exemplo vira a credencial real de quem so copiou e colou. + - INITIAL_PASSWORD=${INITIAL_PASSWORD:?defina INITIAL_PASSWORD no .env} + - JWT_SECRET=${JWT_SECRET:?defina JWT_SECRET no .env (openssl rand -hex 32)} + - REQUIRE_API_KEY=false + # Aceitavel apenas porque a porta acima esta presa em 127.0.0.1. + - REQUIRE_LOGIN=false volumes: - - .:/app - working_dir: /app + - gateway_data:/app/data + healthcheck: + test: + - CMD-SHELL + - >- + node -e "require('http').get('http://127.0.0.1:20128/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))" + interval: 10s + timeout: 5s + retries: 20 + # O primeiro boot roda migracoes e cria o banco. + start_period: 45s + + ominirtksync: + build: . + image: ghcr.io/pathbit/ominirtksync:local + container_name: ominirtksync-test-sync + restart: unless-stopped + ports: + # Porta interna 9090 nos tres sincronizadores; publicada em 19092 aqui. + - "127.0.0.1:19092:9090" environment: - - PYTHONPATH=/app/src - command: ["python3", "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"] + - DATA_DIR=/app/data + - DB_PATH=/app/data/storage.sqlite + - OMNIROUTE_URL=http://omniroute:20128 + - WEB_PORT=9090 + - SYNC_INTERVAL=60 + - REFRESH_MARGIN=900 + - CRON_ENABLED=1 + - LOG_DIR=/app/data/logs + - LOG_LEVEL=INFO + # Desligada na stack de teste: o CI nao deve sair para a internet. + - CREDENTIAL_CHECK_ENABLED=0 + volumes: + - gateway_data:/app/data + depends_on: + omniroute: + condition: service_healthy + 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: + gateway_data: diff --git a/docs/Egress-And-Multi-Session.md b/docs/Egress-And-Multi-Session.md new file mode 100644 index 0000000..765d392 --- /dev/null +++ b/docs/Egress-And-Multi-Session.md @@ -0,0 +1,213 @@ +# Multiple sessions on one account: where the traffic comes from + +*(Versão em português ao final.)* + +Providers let one account hold several concurrent sessions. What causes trouble +is not the number of sessions — it is **how they look from the outside**. When a +gateway fans many accounts out through a single machine, every one of those +accounts shows the same source address, and the pattern is what draws attention. + +This page documents what the gateway already gives you to control that, and what +the synchronizer shows about it. Every schema detail below was read from the running `decolua/9router` and +`diegosouzapw/omniroute` images — each section names the gateway it describes. +Nothing here is a claim about any provider's policy, which we cannot verify and +do not restate. + +--- + +## What OmniRoute already models + +Three tables, all present in the shipped schema: + +| Table | What it holds | +| --- | --- | +| `proxy_registry` | The egress endpoints: `host`, `port`, `username`, `password`, `region`, `country_code`, plus measured `latency_ms`, `quality_score`, `anonymity` and `google_access`. | +| `proxy_assignments` | The binding: `proxy_id`, `scope`, `scope_id`, `position`. Unique on `(scope, scope_id, proxy_id)`. | +| `proxy_scope_rotation` | Per scope: `strategy`, `cursor`, `sticky_window_minutes`, `rotated_at`. | + +`scope` takes three values in the code: **`global`**, **`provider`** and +**`account`**. Two more switches live on the connection row itself: +`provider_connections.proxy_enabled` and `per_key_proxy_enabled`. + +### The property that matters + +The selector short-circuits when a scope resolves to exactly one proxy — with a +single entry it is returned directly, without consulting rotation at all. + +That gives you the arrangement you want with no extra machinery: + +> **One proxy assigned at `scope='account'` for a given connection id = that +> account always egresses from that address.** + +Where rotation *is* involved, `sticky_window_minutes` keeps the same choice for +a window; the stored default when a scope row is created is **30 minutes**. +Rotation is the opposite of what you want per account: it makes one account +appear from several addresses over time. + +### How to set it up + +Through OmniRoute's own screens and API — the synchronizer never writes here: + +1. Register each egress under **Settings → Proxies** (`/api/settings/proxies`). +2. Bind one to the connection, choosing the **account** scope, so the assignment + lands with `scope='account'` and `scope_id` equal to the connection id. +3. Turn on `proxy_enabled` for that connection. +4. Leave exactly **one** proxy in that scope. One entry means no rotation. + +Verification, without leaving the box: + +```sql +SELECT c.name, + COALESCE(NULLIF(r.name,''), r.host || ':' || r.port) AS egress +FROM provider_connections c +LEFT JOIN proxy_assignments a ON a.scope = 'account' AND a.scope_id = c.id +LEFT JOIN proxy_registry r ON r.id = a.proxy_id +ORDER BY c.name; +``` + +Any row with `egress` NULL shares the host's address with every other such row. + +--- + +## What 9Router models + +9Router keeps the pools in the `proxyPools` table and the binding **inside the +connection**, under `providerSpecificData`: + +- `proxyPoolId` — which pool this connection egresses through; +- `connectionProxyEnabled` — the per-connection switch, which must be `true`. + +Same principle, different storage: the binding belongs to the connection rather +than to a separate assignment table. + +--- + +## What the synchronizer shows + +The panel surfaces the binding **read-only**, per connection: + +- **bound** — the account has its own egress, and the panel names it; +- **shared** — no binding; this account leaves through the same address as the + rest; +- **unknown** — the installation is older than the proxy tables. + +The synchronizer never creates, edits or deletes a proxy, an assignment or a +rotation strategy. It reads, and it tells you what it found. Ownership of the +routing stays with the gateway, which is the only component that can actually +apply it to a request. + +--- + +## Tailscale, and what it does and does not solve + +Tailscale is one way to *obtain* egress addresses; it is not a replacement for +the binding above. + +- An **exit node** gives the gateway host one stable outbound address — the + node's. Useful when you want a known, stable address instead of whatever your + ISP hands out. But it is **one address for the whole host**: with several + accounts on one gateway, they all still share it. An exit node alone does not + separate accounts. +- **Several exit nodes** do separate them, but only if each account is bound to + a different one — and the binding is exactly the `scope='account'` assignment + described above. Tailscale supplies the addresses; OmniRoute decides which + account uses which. +- `--advertise-exit-node` plus `--exit-node` are per-host settings. To route + per-account you still need one HTTP/SOCKS endpoint per node, registered in + `proxy_registry` like any other egress. + +The same holds for any provider of addresses — a VPS, a residential proxy, a +second uplink. The part that separates accounts is the per-account binding, not +the technology that produced the address. + +### Reasonable defaults + +- One egress per account when several accounts of the same provider live on one + gateway. +- Prefer **stability over rotation** for a named account: an account whose + address changes every few minutes looks less like a person, not more. +- Keep egress and account in the same region when the provider is + region-sensitive; `proxy_registry.country_code` is there for that. +- Check `google_access` on the registry row before binding a Google-backed + connection to it — the column exists because not every egress can reach it. + +--- + +# Em português + +Provedores aceitam mais de uma sessão ativa por conta. O que costuma dar +problema não é a quantidade de sessões, e sim **como elas aparecem de fora**: +quando um gateway distribui várias contas por uma única máquina, todas saem pelo +mesmo endereço, e é esse padrão que chama atenção. + +Esta página documenta o que o gateway **já oferece** para controlar isso e o que +o sincronizador mostra a respeito. Cada detalhe de schema foi lido das imagens `decolua/9router` e +`diegosouzapw/omniroute` em execução — cada seção diz de qual gateway trata. +Não há aqui nenhuma afirmação sobre política de fornecedor, que não temos como +verificar. + +## O que o OmniRoute já modela + +- `proxy_registry` — os endereços de saída (host, porta, usuário, senha, região, + `country_code`, `google_access`, latência e pontuação de qualidade). +- `proxy_assignments` — o vínculo: `proxy_id`, `scope`, `scope_id`, `position`. +- `proxy_scope_rotation` — por escopo: `strategy`, `cursor`, + `sticky_window_minutes`, `rotated_at`. + +Os escopos no código são **`global`**, **`provider`** e **`account`**. Na própria +linha da conexão existem ainda `proxy_enabled` e `per_key_proxy_enabled`. + +**A propriedade que importa:** quando um escopo resolve para exatamente um +proxy, o seletor devolve esse proxy direto, sem consultar rotação. Ou seja: + +> **Um proxy vinculado em `scope='account'` para o id de uma conexão = aquela +> conta sai sempre pelo mesmo endereço.** + +Onde há rotação, `sticky_window_minutes` mantém a escolha por uma janela — o +padrão gravado ao criar a linha do escopo é de **30 minutos**. Rotação é o +oposto do que se quer por conta: faz uma conta aparecer de vários endereços. + +**Como configurar** (pelas telas e pela API do próprio OmniRoute; o +sincronizador não escreve nada disso): + +1. Cadastre cada saída em **Settings → Proxies**. +2. Vincule uma à conexão escolhendo o escopo **account**. +3. Ligue `proxy_enabled` naquela conexão. +4. Deixe **um único** proxy nesse escopo — um só significa sem rotação. + +## O que o 9Router modela + +Os pools ficam em `proxyPools` e o vínculo vive **dentro da conexão**, em +`providerSpecificData`: `proxyPoolId` diz por qual pool ela sai, e +`connectionProxyEnabled` precisa estar `true`. Mesmo princípio, armazenamento +diferente. + +## O que o painel mostra + +Somente leitura, por conexão: **vinculada** (com o nome da saída), +**compartilhada** (sai junto com as outras) ou **desconhecida** (instalação +anterior às tabelas de proxy). O sincronizador nunca cria, edita nem apaga +proxy, vínculo ou estratégia — quem manda no roteamento é o gateway, o único que +consegue de fato aplicá-lo a uma requisição. + +## Tailscale: o que resolve e o que não resolve + +- Um **exit node** dá ao host do gateway um endereço de saída estável — o do + nó. É útil para ter um endereço conhecido em vez do que o provedor de internet + entregar. Mas é **um endereço para o host inteiro**: com várias contas no + mesmo gateway, todas continuam compartilhando. Exit node sozinho não separa + contas. +- **Vários exit nodes** separam, desde que cada conta esteja vinculada a um + deles — e o vínculo é exatamente o `scope='account'` acima. O Tailscale + fornece os endereços; o OmniRoute decide qual conta usa qual. +- Para rotear por conta você ainda precisa de um endpoint HTTP/SOCKS por nó, + cadastrado em `proxy_registry` como qualquer outra saída. + +Vale o mesmo para qualquer origem de endereços — um VPS, um proxy residencial, +um segundo link. O que separa contas é o vínculo por conta, não a tecnologia que +produziu o endereço. + +**Padrões razoáveis:** uma saída por conta quando várias contas do mesmo +provedor dividem um gateway; preferir **estabilidade a rotação** para conta +nomeada; manter saída e conta na mesma região quando o provedor for sensível a +isso; e conferir `google_access` antes de vincular uma conexão do Google. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md new file mode 100644 index 0000000..30a5f67 --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,138 @@ +# 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. | + + +## Encryption at rest + +OmniRoute encrypts `access_token`, `refresh_token` and `api_key` in place, prefixing the stored +value with `enc:v1:` (`src/lib/db/encryption.ts`). The synchronizer writes plaintext, and that is +safe on purpose: `decrypt()` returns any value without the prefix unchanged — the path it labels +"legacy plaintext or passthrough mode" — and the gateway re-encrypts the row on its next write. + +Two consequences worth knowing: + +- A token this synchronizer wrote shows up in the database as plaintext until OmniRoute touches + the row again. That is not a leak of anything new: whoever can read `storage.sqlite` could + already read the decryption key next to it. +- Never write a value that already starts with `enc:v1:` back as if it were a token. It is + ciphertext, not a credential, and `encrypt()` deliberately refuses to double-encrypt it. diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md new file mode 100644 index 0000000..47dd519 --- /dev/null +++ b/docs/wiki/Authentication.md @@ -0,0 +1,122 @@ +# 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 ──►│ recovery credential │ + └──────────────────────────┘ +``` + +--- + +## First sign-in + +**There is no factory password.** A static default is a public credential: it ships in the +README, gets copied into every deployment, and is the first thing anyone tries. So the panel +has none. + +On first boot a random recovery credential is generated, written to +`/.dashboard_recovery` with mode `0600`, and **never printed to the log** — the +container's stdout is routinely collected, forwarded and read by many people. Read it once: + +```bash +docker exec cat /app/data/db/.dashboard_recovery +``` + +Sign in with user `admin` and that value, then set your own password on the screen. Until you +do, the dashboard shows a security banner. + +To skip this entirely, set `DASHBOARD_USER` and `DASHBOARD_PASSWORD` in the environment +(see [Headless mode](#headless-mode)), or pin your own recovery value with +`DASHBOARD_RECOVERY_HASH`. + +--- + +## 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..cd65b78 --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,119 @@ +# 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`). | +| `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 +> 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` | *(empty)* | Panel password. Left empty, the first sign-in uses the recovery credential generated on first boot. | +| `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..88ff208 --- /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 — the Makefile creates the virtualenv for you: + +```bash +make venv && make test +``` + +--- + +## 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) 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..42c0d51 --- /dev/null +++ b/src/omini_rtksync/auth.py @@ -0,0 +1,231 @@ +"""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" + +# 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.""" + 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() + + +# --- 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): + return None + try: + with open(auth_file, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + # Arquivo sintaticamente valido mas com forma errada -- "[]", um + # numero, uma string. Sem esta guarda o .get abaixo levantava + # AttributeError, que subia ate o handler HTTP e trancava para fora + # tanto a credencial configurada quanto a de recuperacao. + return None + user = data.get("user") + password = data.get("password") + if user and password: + return str(user), str(password) + except (OSError, ValueError): + # Arquivo ausente, ilegivel ou com JSON quebrado equivale a "sem + # credencial gravada": quem chama cai para o ambiente ou para a + # credencial de recuperacao. + pass + 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() + 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: + # Disco cheio, permissao negada, arquivo removido no meio do + # caminho: tratado como "nao ha credencial de recuperacao", que e + # exatamente o estado em que a autenticacao normal decide sozinha. + 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. + # 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 password_matches( + stored_password, 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..6aa3b10 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -1,53 +1,126 @@ -"""CLI and orchestrator of OminiRTKSync for OmniRoute.""" +"""CLI e orquestrador do OminiRTKSync para OmniRoute.""" import argparse import os import signal import sys +import threading import time from datetime import datetime +from typing import Any, Dict, List from .config import Settings +from .credential_check import STATE_INVALID, STATE_VALID, check_oauth_token +from .logs import get_logger, setup_logging from .cron import CronScheduler -from .database import get_all_combos, get_all_connections, update_connection +from .database import ( + get_all_combos, + get_all_connections, + normalize_expiry_format, + update_connection, + update_connection_health, +) from .discovery import HostDiscoveryEngine from .normalizer import parse_expiry_to_ms from .providers import ApiKeyProvider, GenericOAuthProvider, GoogleProvider, LocalProvider from .web import start_omini_web +# Prefixos que descrevem falha. Emitir tudo em INFO fazia com que +# LOG_LEVEL=WARNING escondesse justamente os eventos que motivaram o log +# persistente: quem sobe o nível para reduzir ruído perdia toda falha. +PREFIXOS_DE_ERRO = {"FALHA", "ERRO", "ERROR", "FAILURE"} +PREFIXOS_DE_AVISO = {"AVISO", "WARN", "WARNING"} + + 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). + + O nível segue o prefixo: falha vai como ERROR, aviso como WARNING, o resto + como INFO. + """ + logger = get_logger() + mensagem = f"[{prefix}] {text}" + alvo = str(prefix).upper() + if alvo in PREFIXOS_DE_ERRO: + logger.error(mensagem) + elif alvo in PREFIXOS_DE_AVISO: + logger.warning(mensagem) + else: + logger.info(mensagem) class OmniSyncEngine: def __init__(self, settings: Settings): self.settings = settings + # O botao da tela e o cron chamam esta mesma instancia, e o servidor web + # atende cada requisicao em uma thread. Sem isto, duas renovacoes OAuth + # concorrentes podem sobrescrever um token recem-rotacionado. + self._sync_lock = threading.Lock() self.discovery = HostDiscoveryEngine( host_home=settings.host_home, extra_paths=settings.credential_paths, ) 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): + """Executa um ciclo completo, serializado. + + Uma execucao manual pela tela e uma execucao agendada nunca podem se + sobrepor, ou duas renovacoes OAuth concorrentes sobrescrevem o token uma + da outra. + """ + with self._sync_lock: + return self._sync_all_locked() + + def _sync_all_locked(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 + normalized = 0 now_ms = int(time.time() * 1000) + detalhes: List[Dict[str, Any]] = [] + erros: List[str] = [] for c in conns: provider = c["provider"] cid = c["id"] name = c["name"] + # O histórico da tela consome esta lista. Sem ela, todo ciclo bem + # sucedido aparecia com log vazio e "nenhum ciclo executado ainda". + detalhe: Dict[str, Any] = { + "id": cid, + "provider": provider, + "name": name, + "actions": [], + } + detalhes.append(detalhe) + + # Cura o formato de expiração para QUALQUER provedor OAuth, não só + # para o ramo do Antigravity. Um epoch numérico em texto é Invalid + # Date para o OmniRoute; se a renovação falhar — refresh token + # revogado, client credentials ausentes — o valor ilegível + # permanecia para sempre justamente no caso em que mais importa. + bruto_expiracao = str(c.get("expiresAt") or "") + if bruto_expiracao.isdigit(): + exp_curado = parse_expiry_to_ms(c.get("expiresAt")) + if exp_curado and normalize_expiry_format(self.settings.db_path, cid, exp_curado): + normalized += 1 + nota = "expires_at normalizado para ISO-8601" + log_msg("STATUS", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) # 1. Google / Antigravity OAuth if provider in ("antigravity", "gemini-cli"): @@ -59,6 +132,32 @@ def sync_all(self): exp_ms = parse_expiry_to_ms(c.get("expiresAt")) rem_sec = int((exp_ms - now_ms) / 1000) if exp_ms else 0 + # Pergunta ao Google se o token ainda vale, em vez de deduzir + # isso da validade guardada. Um token revogado cuja expiração + # gravada ainda está no futuro continuava sendo exibido como + # ativo — que é exatamente o caso que o painel precisa mostrar. + if self.settings.validate_credentials and c.get("accessToken"): + veredito = check_oauth_token( + str(c.get("accessToken")), timeout=self.settings.validation_timeout + ) + if veredito.state == STATE_INVALID: + nota = f"Token de acesso RECUSADO pelo Google ({veredito.detail})" + log_msg("FALHA", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) + update_connection_health( + self.settings.db_path, + cid, + test_status="invalid", + credential_state=veredito.state, + last_error=veredito.detail, + ) + # Recusado é motivo para renovar agora, não daqui a pouco. + rem_sec = 0 + elif veredito.state == STATE_VALID: + update_connection_health( + self.settings.db_path, cid, credential_state=veredito.state + ) + if rem_sec <= self.settings.refresh_margin or not c.get("accessToken"): if ref_tok: client_id = os.environ.get("GOOGLE_CLIENT_ID", "") @@ -103,19 +202,24 @@ def sync_all(self): expires_at_ms=new_exp_ms, ) refreshed += 1 - log_msg("SUCCESS", f"[{provider} · {name}] OAuth refreshed successfully ({exp_in}s)") + detalhe["actions"].append(f"OAuth renovado ({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}") + nota = f"Erro ao renovar OAuth: {err}" + log_msg("FALHA", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) + erros.append(f"[{provider} · {name}] {nota}") 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: log_msg("STATUS", f"[{provider} · {name}] {note}") + detalhe["actions"].append(note) if mod and data: update_connection( self.settings.db_path, @@ -125,29 +229,64 @@ 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") + detalhe["actions"].append("Credenciais OAuth atualizadas") + 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) + renovou, data, notes = self.api_provider.check_and_refresh(c) for note in notes: log_msg("STATUS", f"[{provider} · {name}] {note}") - if mod and data: + detalhe["actions"].append(note) + if data: + # O resultado da sondagem tem de ir para o banco. Sem isto o + # painel recarregava a linha antiga e uma chave recusada + # continuava verde na tela. + update_connection_health( + self.settings.db_path, + cid, + test_status=data.get("testStatus"), + credential_state=data.get("credentialState"), + last_error=data.get("lastError"), + # O provider ja apagou a trava vencida de `data`, entao + # inferir "limpar" da ausencia dela invertia o sentido e + # preservava justamente a trava que devia sair. Quem diz + # e a mensagem do provider. + clear_rate_limit=any("rateLimitedUntil" in n for n in notes), + ) + if renovou: 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) + _, data, notes = self.local_provider.check_and_refresh(c) for note in notes: log_msg("STATUS", f"[{provider} · {name}] {note}") + detalhe["actions"].append(note) + if data: + update_connection_health( + self.settings.db_path, + cid, + test_status=data.get("testStatus"), + discovered_models=data.get("discoveredModels"), + last_error=data.get("lastError"), + ) 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} + return { + "success": not erros, + "total": len(conns), + "refreshed": refreshed, + "normalized": normalized, + # O histórico por execução da tela lê estes dois campos. + "details": detalhes, + "errors": erros, + } def print_status(settings: Settings): @@ -155,27 +294,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 +325,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,42 +365,65 @@ 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, para o operador conseguir + # voltar ao painel caso esqueca a senha trocada pela tela. + # + # O valor NAO vai para o log. Ele e uma credencial funcional, e o stdout do + # container costuma ser coletado, encaminhado e lido por muita gente; fica + # apenas no arquivo com modo 0600, e o log diz onde encontra-lo. + recovery_hash, generated_now = settings.ensure_recovery_hash() + if generated_now and recovery_hash: + logger.warning( + "[AUTH] Credencial de recuperacao gerada para o usuario 'admin'. Leia com: " + "docker exec cat %s (ou fixe a sua com DASHBOARD_RECOVERY_HASH)", + settings.get_recovery_file_path(), + ) + if args.db_path: settings.db_path = args.db_path if args.interval: settings.sync_interval = args.interval + # O agendador le cron_interval, ja derivado do ambiente antes de as + # opcoes chegarem aqui: sem esta linha --interval era um no-op no cron. + settings.cron_interval = args.interval if args.margin: settings.refresh_margin = args.margin if args.no_web: @@ -280,7 +442,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..6edb92b 100644 --- a/src/omini_rtksync/config.py +++ b/src/omini_rtksync/config.py @@ -1,12 +1,23 @@ -"""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, + read_db_credentials, + write_db_credentials, + validate_password_strength, + 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 +39,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,21 +47,96 @@ 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" + # Sem senha de fabrica: um valor estatico e, por definicao, uma credencial + # publica. O primeiro acesso e feito com a credencial de recuperacao, que e + # sorteada no primeiro boot e gravada com modo 0600. + dashboard_password: str = "" 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 + # 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 + # 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.""" + 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_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: base_dir = os.path.dirname(self.db_path) - if not base_dir or not os.path.exists(base_dir): + # O diretorio e CRIADO, nao contornado. No primeiro boot ele ainda nao + # existe -- o gateway e quem o cria ao subir -- e cair para $HOME + # gravava o banco de preferencias, com a senha do painel dentro, fora do + # volume de dados: a senha sumia ao recriar o container, e o painel + # voltava a pedir a credencial de recuperacao. + if base_dir: + try: + os.makedirs(base_dir, exist_ok=True) + except OSError: + # Caminho somente leitura ou invalido: ai sim nao ha onde + # gravar, e $HOME e o unico lugar que resta. + base_dir = "" + if not base_dir or not os.path.isdir(base_dir): base_dir = os.path.expanduser("~") 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: @@ -66,22 +152,48 @@ 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 pela comparação com um texto fixo qualquer. + """ + 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: - 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: + """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 + + 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: + # O arquivo ja pode nao existir -- e o caso comum, porque a senha + # nasce direto no banco. Falhar aqui reverteria uma troca de senha + # que ja deu certo. + pass + return True + @classmethod def from_env(cls, env_file: str = ".env") -> "Settings": load_dotenv(env_file) @@ -103,7 +215,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 +232,22 @@ 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") + # Quem manda no modo "credencial gerida pelo ambiente" e a SENHA, nunca o + # nome de usuario: nome sozinho nao e credencial. Com a regra anterior, + # o docker-compose de exemplo (DASHBOARD_USER=admin e + # DASHBOARD_PASSWORD vazia) marcava a autenticacao como autoritativa do + # ambiente, e o painel recusava para sempre definir a senha pela tela. + # A instalacao ficava presa na credencial de recuperacao e o aviso de + # seguranca nunca sumia, porque nunca havia senha gravada no SQLite. + 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 "" + auth_from_env = bool(d_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 +257,14 @@ 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, + 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..3f65ed0 --- /dev/null +++ b/src/omini_rtksync/credential_check.py @@ -0,0 +1,286 @@ +"""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, replace +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 + # 5xx nao prova nada sobre a credencial: o provedor e que esta com problema. + # Tratar como valido carimbava "ativo" numa chave que nunca foi verificada. + if status >= 500: + return STATE_UNREACHABLE + 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 _same_host(a: str, b: str) -> bool: + """Whether two URLs point at the same host (port included).""" + try: + return urllib.parse.urlparse(a).netloc.lower() == urllib.parse.urlparse(b).netloc.lower() + except Exception: + return False + + +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) + + # Um endereco declarado na conexao manda mais do que o nome do provedor. + # "azure-openai" casa com "openai" por substring, e um "anthropic" atras de + # proxy tambem casa: sem esta checagem a chave do cliente sairia daqui para + # api.openai.com ou api.anthropic.com, que nao e para onde ela deveria ir. + if spec is not None and base_url and not _same_host(base_url, spec.url): + # So o ENDERECO muda. O jeito de autenticar continua sendo o do + # fornecedor: a Anthropic espera x-api-key e o Gemini x-goog-api-key, e + # trocar isso por um Bearer generico faria o proxy recusar uma chave + # perfeitamente valida. + spec = replace(spec, url=base_url.rstrip("/") + "/models") + + 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/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/database.py b/src/omini_rtksync/database.py index 28474cf..0e686e8 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() @@ -26,8 +26,63 @@ def detect_connection_table(conn: sqlite3.Connection) -> str: return "provider_connections" +def _decode_json(value: Any) -> Optional[Dict[str, Any]]: + """Le uma coluna JSON tolerando texto vazio, NULL e dicionario ja decodificado.""" + if isinstance(value, dict): + return value + if not value: + return None + try: + decoded = json.loads(value) + except (TypeError, ValueError): + return None + return decoded if isinstance(decoded, dict) else None + + +def _anexar_saida_de_rede(conn: sqlite3.Connection, conexoes: List[Dict[str, Any]]) -> None: + """Resolve, por conexao, qual saida de rede o OmniRoute usaria. + + O vinculo vive em ``proxy_assignments`` com ``scope='account'`` e + ``scope_id`` igual ao id da conexao; o proxy em si esta em + ``proxy_registry``. Tudo em uma consulta so, e em silencio quando a + instalacao e antiga demais para ter essas tabelas. + + Somente leitura: nada aqui escreve no banco. + """ + try: + existentes = { + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name IN ('proxy_assignments','proxy_registry')" + ) + } + if {"proxy_assignments", "proxy_registry"} - existentes: + return + + vinculos: Dict[str, str] = {} + for linha in conn.execute( + "SELECT a.scope_id, COALESCE(NULLIF(r.name,''), r.host || ':' || r.port) AS saida " + "FROM proxy_assignments a JOIN proxy_registry r ON r.id = a.proxy_id " + "WHERE a.scope = 'account' AND a.scope_id IS NOT NULL " + "ORDER BY a.position ASC" + ): + # position ASC e o primeiro vence: e a saida que a rotacao entrega + # quando o escopo tem um unico proxy vinculado. + vinculos.setdefault(str(linha[0]), str(linha[1])) + + for c in conexoes: + saida = vinculos.get(c["id"]) + if saida: + c["egressProxy"] = saida + except sqlite3.Error: + # Schema mais antigo ou banco em uso por outro processo: a coluna de + # saida simplesmente nao aparece, sem derrubar a listagem inteira. + return + + 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 +94,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 +103,8 @@ 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 "active" - # If JSON 'data' field is present (9Router style schema), merge fields + # Se houver campo JSON 'data' (formato 9Router), funde os campos + extra: Dict[str, Any] = {} if "data" in keys and isinstance(item["data"], str): try: d = json.loads(item["data"]) @@ -57,9 +113,16 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: api_key = api_key or d.get("apiKey") expires_at = expires_at or d.get("expiresAt") test_status = test_status or d.get("testStatus") + if isinstance(d, dict): + extra = d except Exception: pass + # provider_specific_data e onde o OmniRoute guarda baseUrl e afins. + specific = _decode_json(item.get("provider_specific_data")) or _decode_json( + extra.get("providerSpecificData") + ) + result.append({ "id": str(item["id"]), "provider": provider, @@ -71,17 +134,73 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: "testStatus": test_status, "isOAuth": bool(access_token or refresh_token), "hasApiKey": bool(api_key), - "raw": item, + # Campos de saude, projetados um a um. A linha crua do banco NAO + # e devolvida: ela carrega access_token, refresh_token e api_key, + # e qualquer consumidor que a serializasse por engano publicaria + # as tres coisas de uma vez. + "providerSpecificData": specific, + "baseUrl": (specific or {}).get("baseUrl") or (specific or {}).get("baseURL"), + "discoveredModels": (specific or {}).get("discoveredModels") + or extra.get("discoveredModels") + or [], + "credentialState": (specific or {}).get("credentialState") + or extra.get("credentialState"), + "lastTested": item.get("last_tested") or extra.get("lastTested"), + "lastHealthCheckAt": item.get("last_health_check_at"), + "rateLimitedUntil": item.get("rate_limited_until") or extra.get("rateLimitedUntil"), + "lastError": item.get("last_error"), + "updatedAt": item.get("updated_at") or item.get("updatedAt"), + # Saida de rede: interruptores por conexao do proprio OmniRoute. + # O vinculo em si vem de proxy_assignments, resolvido abaixo. + "proxyEnabled": bool(item.get("proxy_enabled")), + "perKeyProxyEnabled": bool(item.get("per_key_proxy_enabled")), + "egressProxy": None, }) + + _anexar_saida_de_rede(conn, result) return result finally: 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 normalize_expiry_format(db_path: str, connection_id: str, expires_at_ms: int) -> bool: + """Regrava expires_at em ISO-8601 sem tocar nos tokens. + + A cura de formato acontecia so junto de uma renovacao bem-sucedida. Quando a + renovacao falha -- refresh token revogado, client_id ausente -- o epoch + numerico gravado como texto permanecia, e e justamente ele que o OmniRoute + le com `new Date(...)` e obtem Invalid Date, desligando a propria renovacao + preventiva. O formato e curado de qualquer jeito. + """ + conn = get_db_connection(db_path) + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + try: + tbl = detect_connection_table(conn) + cursor = conn.cursor() + cursor.execute( + f"UPDATE {tbl} SET expires_at = ?, updated_at = ? WHERE id = ?", + (to_iso_utc(expires_at_ms), now_iso, connection_id), + ) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error: + return False + finally: + conn.close() + 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,21 +210,28 @@ def update_connection( cols = [c["name"] for c in cursor.fetchall()] if "access_token" in cols: - # OmniRoute relational schema (provider_connections) - try: - exp_iso = datetime.fromtimestamp(expires_at_ms / 1000.0, timezone.utc).isoformat().replace("+00:00", "Z") - except Exception: - exp_iso = str(expires_at_ms) + # 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 = 'active', updated_at = ? WHERE id = ? """, - (access_token, refresh_token, exp_iso, 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 = {} @@ -118,6 +244,9 @@ def update_connection( if refresh_token: d["refreshToken"] = refresh_token d["expiresAt"] = expires_at_ms + # '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 = ?", @@ -129,8 +258,84 @@ def update_connection( conn.close() +def update_connection_health( + db_path: str, + connection_id: str, + *, + test_status: Optional[str] = None, + credential_state: Optional[str] = None, + discovered_models: Optional[List[str]] = None, + last_error: Optional[str] = None, + clear_rate_limit: bool = False, +) -> bool: + """Grava o resultado de uma sondagem, sem tocar em token nenhum. + + Antes disto o provider devolvia `testStatus`, `credentialState` e o catalogo + descoberto, e o motor jogava tudo fora: o painel recarregava a linha antiga e + nunca mostrava que uma chave havia sido recusada nem que a instancia local + estava fora do ar. + + Só escreve em coluna que existe no banco — o schema do OmniRoute evolui entre + versões, e uma instalação mais antiga não pode quebrar por causa disso. + """ + conn = get_db_connection(db_path) + try: + tbl = detect_connection_table(conn) + cursor = conn.cursor() + cols = {c[1] for c in cursor.execute(f"PRAGMA table_info({tbl})")} + now_iso = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + campos: List[str] = [] + valores: List[Any] = [] + + if test_status and "test_status" in cols: + campos.append("test_status = ?") + valores.append(test_status) + if "last_tested" in cols: + campos.append("last_tested = ?") + valores.append(now_iso) + if "last_health_check_at" in cols: + campos.append("last_health_check_at = ?") + valores.append(now_iso) + if last_error is not None and "last_error" in cols: + campos.append("last_error = ?") + valores.append(last_error or None) + if clear_rate_limit and "rate_limited_until" in cols: + campos.append("rate_limited_until = ?") + valores.append(None) + + # credentialState e o catalogo local nao tem coluna propria: vao para o + # JSON de provider_specific_data, preservando o que ja estava la. + if (credential_state or discovered_models is not None) and "provider_specific_data" in cols: + cursor.execute(f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,)) + linha = cursor.fetchone() + atual = _decode_json(linha["provider_specific_data"]) if linha else None + atual = dict(atual or {}) + if credential_state: + atual["credentialState"] = credential_state + atual["credentialCheckedAt"] = now_iso + if discovered_models is not None: + atual["discoveredModels"] = discovered_models + campos.append("provider_specific_data = ?") + valores.append(json.dumps(atual)) + + if "updated_at" in cols: + campos.append("updated_at = ?") + valores.append(now_iso) + + if not campos: + return False + + valores.append(connection_id) + cursor.execute(f"UPDATE {tbl} SET {', '.join(campos)} WHERE id = ?", valores) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + 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() @@ -156,4 +361,3 @@ def get_all_combos(db_path: str) -> List[Dict[str, Any]]: return result finally: conn.close() - 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..f0b3d01 --- /dev/null +++ b/src/omini_rtksync/i18n.py @@ -0,0 +1,371 @@ +"""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": "no password has been set for this dashboard yet, so access still " + "depends on the one-time recovery credential. Set your own now, or " + "define DASHBOARD_USER/DASHBOARD_PASSWORD in the environment.", + "auth.updated_title": "Credentials updated", + "auth.updated_body": "Your new password is now in effect. The browser is still holding the " + "previous one, so sign in again to continue.", + "auth.updated_link": "Back to the dashboard", + "auth.required": "Authentication required.", + "auth.required_body": "This dashboard is private. Sign in to continue.", + "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.active": "Active", + "health.expiring_soon": "Expiring", + "health.expired": "Expired", + "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", + "duration.unknown_expiry": "Expiry unknown", + "duration.no_expiry": "No expiry (static key)", + "table.last_refresh": "Last renewal", + "table.never_refreshed": "Never renewed", + "table.time_ago": "{elapsed} ago", + "security.cross_origin": "Request rejected: it did not come from this dashboard. Reload the page and try again.", + "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.", + "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", + "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": "nenhuma senha foi definida para este painel ainda, então o acesso " + "ainda depende da credencial de recuperação. Defina a sua agora, ou " + "configure DASHBOARD_USER/DASHBOARD_PASSWORD no ambiente.", + "auth.updated_title": "Credenciais atualizadas", + "auth.updated_body": "A nova senha já está em vigor. O navegador ainda guarda a anterior, " + "então autentique-se de novo para continuar.", + "auth.updated_link": "Voltar ao painel", + "auth.required": "Autenticação requerida.", + "auth.required_body": "Este painel é privado. Autentique-se para continuar.", + "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.active": "Ativo", + "health.expiring_soon": "Expirando", + "health.expired": "Expirado", + "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", + "duration.unknown_expiry": "Validade desconhecida", + "duration.no_expiry": "Sem expiração (chave estática)", + "table.last_refresh": "Última renovação", + "table.never_refreshed": "Nunca renovada", + "table.time_ago": "há {elapsed}", + "security.cross_origin": "Requisição recusada: ela não veio deste painel. Recarregue a página e tente de novo.", + "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.", + "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", + "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": "todavía no se ha definido una contraseña para este panel, por lo que " + "el acceso aún depende de la credencial de recuperación. Defina la suya " + "ahora, o configure DASHBOARD_USER/DASHBOARD_PASSWORD en el entorno.", + "auth.updated_title": "Credenciales actualizadas", + "auth.updated_body": "La nueva contraseña ya está vigente. El navegador todavía guarda la " + "anterior, así que vuelva a autenticarse para continuar.", + "auth.updated_link": "Volver al panel", + "auth.required": "Autenticación requerida.", + "auth.required_body": "Este panel es privado. Autentíquese para continuar.", + "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.active": "Activo", + "health.expiring_soon": "Por expirar", + "health.expired": "Expirado", + "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", + "duration.unknown_expiry": "Validez desconocida", + "duration.no_expiry": "Sin expiración (clave estática)", + "table.last_refresh": "Última renovación", + "table.never_refreshed": "Nunca renovada", + "table.time_ago": "hace {elapsed}", + "security.cross_origin": "Solicitud rechazada: no provino de este panel. Recargue la página e inténtelo de nuevo.", + "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.", + "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", + "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..41a2352 --- /dev/null +++ b/src/omini_rtksync/models.py @@ -0,0 +1,189 @@ +"""OmniRoute connection model exposing the same interface the dashboard consumes. + +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 +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .normalizer import parse_expiry_to_ms + +# 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", "::1", "host.docker.internal", ".local") + +# Threshold below which a token counts as "expiring soon" (15 min). +EXPIRING_SOON_SECONDS = 900 + + +@dataclass +class ConnectionRecord: + """A provider_connections row seen through the panel's lens.""" + + id: str + provider: str + name: str + data: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_row(cls, row: Dict[str, Any]) -> "ConnectionRecord": + """Build the record from the dictionary returned by 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 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 really points at an instance on this machine. + + 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. + """ + 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 + return ( + self.data.get("baseUrl") + or self.data.get("base_url") + or None + ) + + @property + def local_models(self) -> List[str]: + """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] + return [str(m) for m in models if m] + + @property + def egress_status(self) -> str: + """Como esta conexao sai para a internet: ``bound``, ``shared`` ou ``unknown``. + + Somente leitura: quem manda no vinculo e o gateway. O OmniRoute guarda + os interruptores em ``provider_connections`` (``proxy_enabled`` e + ``per_key_proxy_enabled``) e o vinculo em si em ``proxy_assignments``, + com ``scope='account'`` e ``scope_id`` igual ao id da conexao. E exibido + aqui porque uma conta que compartilha o mesmo endereco de saida com + todas as outras e justamente o estado que o operador quer perceber, e + nada no painel mostrava isso. + """ + if self.data.get("proxyEnabled") is True or self.data.get("perKeyProxyEnabled") is True: + return "bound" if self.data.get("egressProxy") else "shared" + return "shared" if "proxyEnabled" in self.data else "unknown" + + @property + def egress_binding(self) -> Optional[str]: + """Nome ou identificador da saida vinculada, quando ha uma.""" + vinculo = self.data.get("egressProxy") + return str(vinculo) if vinculo else None + + @property + def expires_at_ms(self) -> Optional[int]: + """Expiry normalized to epoch milliseconds, whether ISO or numeric.""" + 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 last_refresh_at(self) -> Optional[str]: + """Quando a credencial foi renovada/verificada pela ultima vez. + + lastRefreshAt e gravado na renovacao de OAuth; lastTested, na validacao + da credencial. Sem expor isto, o painel diz "0 renovadas" e nao ha como + saber se a ultima renovacao foi ha um minuto ou ha uma semana. + """ + return ( + self.data.get("lastRefreshAt") + or self.data.get("credentialCheckedAt") + or self.data.get("lastTested") + or None + ) + + @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. + + 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" + + if self.is_oauth: + remaining = self.remaining_seconds + if remaining is None: + return "no_expiration" + if remaining <= 0: + return "expired" + if remaining < EXPIRING_SOON_SECONDS: + return "expiring_soon" + return "active" + + if self.has_api_key: + if self.data.get("rateLimitedUntil"): + return "rate_limited" + # 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/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..1ec2aa9 100644 --- a/src/omini_rtksync/providers.py +++ b/src/omini_rtksync/providers.py @@ -1,15 +1,26 @@ -"""Universal token and connection providers for OmniRoute.""" +"""Provedores universais de tokens e conexões para OmniRoute.""" import json import os import time +import urllib.error import urllib.parse 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: - """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 +66,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 +96,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 +120,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 +131,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,52 +182,219 @@ 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.""" - - def __init__(self, discovery: Optional[Any] = None): + """Gerenciador e sanitizador para conexões de API Key no OmniRoute.""" + + 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")) + # Uma instancia local carrega uma chave de fachada, entao `hasApiKey` + # sozinho tambem casaria com ela. Como o motor consulta este provider + # antes do LocalProvider, o catalogo local nunca seria descoberto -- e + # por isso que o Ollama local aparecia sem modelo nenhum no painel. + return bool(conn.get("hasApiKey")) and not LocalProvider.is_local_connection(conn) def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: messages = [] + # `modified` decide se vale gravar; `renewed` decide se conta como + # renovacao no resumo do ciclo. Carimbar o horario de uma verificacao + # muda a linha, mas nao renovou credencial nenhuma -- e contar isso + # inflava o "N renovadas" do cron. modified = False + renewed = False 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 + renewed = 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})") + + # 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("API key operational and healthy") + messages.append("Chave de API inalterada") + + return renewed, res if modified else None, messages - 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 for local OpenAI-compatible connections (Ollama, vLLM) in OmniRoute.""" + """Health monitor for local OpenAI-compatible instances (Ollama, vLLM, LM Studio).""" + + @staticmethod + def _base_url(conn: Dict[str, Any]) -> str: + especifico = conn.get("providerSpecificData") or {} + return str( + conn.get("baseUrl") + or especifico.get("baseUrl") + or especifico.get("baseURL") + or "" + ) + + @classmethod + def is_local_connection(cls, conn: Dict[str, Any]) -> bool: + """Classificacao de "local" compartilhada, para os providers nao brigarem.""" + provider = str(conn.get("provider", "")).lower() + endereco = cls._base_url(conn) + if endereco: + # Endereco declarado decide sozinho. O marcador "ollama" tambem casa + # com a conta hospedada em https://ollama.com/v1, e trata-la como + # local mandaria o sincronizador sondar um catalogo que nao existe + # ali, alem de tirar a conexao do caminho de validacao de chave. + return any(host in endereco for host in LOCAL_HOSTS) + if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): + return True + return False 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")) + if self.is_local_connection(conn): + return True + # Sem token e sem chave nao ha o que outro provider faca com a conexao. + 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 = "" + answered = False + + 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 + + # Chegar aqui significa resposta HTTP valida e JSON parseavel: o + # servico esta de pe, tendo modelo ou nao. + answered = True + models = self._extract_model_names(payload) + if models: + return models, "" + + if answered: + return [], "" + 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, ["Local connection operational"] - + """Sonda a instancia local. + + O primeiro elemento e a contagem de renovacao do ciclo: uma sondagem + nunca renova credencial, entao e sempre False. O dicionario devolvido e + o que deve ser gravado. + """ + models, probe_error = self.discover_models(self._base_url(conn), conn.get("apiKey") or "") + + if models: + return ( + False, + {"discoveredModels": models, "testStatus": "active"}, + [f"Local instance answered with {len(models)} model(s): {', '.join(models[:5])}"], + ) + + # Erro vazio significa que a instancia respondeu com catalogo vazio -- + # instalacao nova, sem modelo baixado. Esta no ar. + if not probe_error: + return ( + False, + {"discoveredModels": [], "testStatus": "active"}, + ["Local instance answered with an empty model catalog"], + ) + + # With no catalog response the connection is not assumed healthy: this is + # exactly the "the local Ollama went down and nobody noticed" case. + return ( + False, + {"testStatus": "unreachable", "lastError": probe_error}, + [f"Local instance did not answer the model catalog: {probe_error}"], + ) diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py new file mode 100644 index 0000000..1fdee38 --- /dev/null +++ b/src/omini_rtksync/render.py @@ -0,0 +1,728 @@ +"""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" +# 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 = { + "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"), + "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"), +} + + +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 render_last_refresh(conn: Any, lang: str) -> str: + """Mostra quando a credencial foi renovada pela ultima vez, e ha quanto tempo.""" + stamp = conn.last_refresh_at + if not stamp: + return f'{esc(translate("table.never_refreshed", lang))}' + + ago = "" + try: + moment = datetime.fromisoformat(str(stamp).replace("Z", "+00:00")) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + elapsed = int((datetime.now(timezone.utc) - moment).total_seconds()) + if elapsed >= 0: + ago = translate("table.time_ago", lang, elapsed=format_duration(elapsed, lang)) + except (ValueError, TypeError): + # Carimbo de tempo em formato desconhecido vira "sem informacao" na + # tela. Uma data ilegivel nao pode derrubar a renderizacao da pagina. + pass + + icon = '' + detail = f'
{esc(ago)}
' if ago else "" + return f'{icon}{esc(format_timestamp(stamp))}{detail}' + + +def render_remaining(conn: Any, lang: str) -> str: + """Validade restante, sem chamar de ilimitado o que so esta faltando. + + Um token OAuth sempre expira. Quando nao ha expiresAt legivel, isso e dado + ausente -- normalmente porque o gateway gravou a validade num formato que + nao soube reler -- e nao uma credencial eterna. So chave estatica pode ser + apresentada como sem expiracao. + """ + remaining = conn.remaining_seconds + if remaining is not None: + return esc(format_duration(remaining, lang)) + + if conn.is_oauth: + return ( + '' + '' + f'{esc(translate("duration.unknown_expiry", lang))}' + ) + return f'{esc(translate("duration.no_expiry", lang))}' + + +def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: + """Pagina autonoma para respostas fora do painel autenticado. + + E o que o navegador exibe quando o usuario aperta ESC no dialogo do Basic + Auth, entao nao pode conter nem credencial nem dica de credencial. + """ + link = ( + f'

{esc(link_label)}

' if link_label else "" + ) + return f""" + + + + + + {esc(title)} + + + + + +
+
+ +

{esc(title)}

+

{esc(body)}

+ {link} +
+
+ +""".encode("utf-8") + + +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["unknown"]) + 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)} + {render_remaining(c, lang)} + {render_last_refresh(c, 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.last_refresh", 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" + # Le a bandeira; o resumo textual nunca serve como booleano. + db_ok = bool(gateway.get("dbOk")) + 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", "—"))})' + 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 "—")} +
    +
    {esc(translate("gateway.diagnostics", lang))}
    +
    {esc(diagnosis)}
    +
    +
    +
    """ + + +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("password.policy", 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)} +
    + +
    + +
    + +
    +
    +
    + +
    {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 191a027..ccc4838 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 @@ -11,17 +11,36 @@ from http import HTTPStatus from http.server import BaseHTTPRequestHandler, HTTPServer, 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, translate +from .models import ConnectionRecord +from .prefs import get_preference, resolve_prefs_path, set_preference +from .render import render_dashboard, render_notice_page + +# 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): - ex = sys.exc_info()[1] - if isinstance(ex, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): + exc = sys.exc_info()[1] + if isinstance(exc, CLIENT_DISCONNECT_ERRORS): return super().handle_error(request, client_address) @@ -42,7 +61,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 @@ -53,7 +71,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 @@ -61,83 +81,228 @@ def require_auth(self) -> bool: if self.check_auth(): return True + lang = self.resolve_language() + payload = render_notice_page( + translate("auth.required", lang), translate("auth.required_body", lang) + ) self.send_response(HTTPStatus.UNAUTHORIZED) self.send_header("WWW-Authenticate", 'Basic realm="OminiRTKSync Dashboard"') - self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") self.end_headers() - self.wfile.write(b"Authentication required. Default credentials: admin / pathbit") + self.write_body(payload) return False + # Politica de seguranca aplicada a TODAS as respostas, nao so a pagina + # principal: o 401, o aviso de credenciais trocadas e os redirects tambem + # sao HTML que o navegador renderiza. + SECURITY_HEADERS = ( + ("Referrer-Policy", "no-referrer"), + ("X-Content-Type-Options", "nosniff"), + ("X-Frame-Options", "DENY"), + ( + "Content-Security-Policy", + # Restrita ao que a pagina realmente carrega: Bootstrap e os icones + # vem do jsDelivr, as fontes do Google. connect-src 'self' porque o + # painel e inteiramente renderizado no servidor, entao um HTML + # injetado nao tem para onde exfiltrar. + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net " + "https://fonts.googleapis.com; " + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com data:; " + # As bandeiras do seletor de idioma sao SVG que o CSS do + # flag-icons busca no mesmo CDN. Sem esta origem elas + # simplesmente nao aparecem, sem erro visivel na tela. + "img-src 'self' data: https://cdn.jsdelivr.net; " + "connect-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "base-uri 'none'" + ), + ) + + def end_headers(self): + """Injeta os cabecalhos de seguranca antes de fechar o bloco.""" + enviados = {k.lower() for k, _ in self._headers_buffer_names()} + for nome, valor in self.SECURITY_HEADERS: + if nome.lower() not in enviados: + self.send_header(nome, valor) + super().end_headers() + + def _headers_buffer_names(self): + """Nomes ja enfileirados nesta resposta, para nao duplicar cabecalho.""" + for linha in getattr(self, "_headers_buffer", []) or []: + try: + texto = linha.decode("latin-1", "ignore") + except Exception: + continue + if ":" in texto: + yield texto.split(":", 1)[0].strip(), texto + def do_GET(self): if self.path == "/healthz": self.serve_healthz() return + # Servida antes de require_auth de proposito: o navegador ainda esta + # com a senha antiga neste instante, e exigir autenticacao aqui daria + # um 401 cru exatamente depois de a troca ter dado certo. + if urlparse(self.path).path == "/credenciais-atualizadas": + self.serve_credentials_updated() + return + 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) + def is_same_origin_request(self) -> bool: + """Rejeita POST disparado por outro site. + + O Basic Auth e anexado automaticamente pelo navegador mesmo em um POST + vindo de outra origem, e um formulario urlencoded nao dispara preflight. + Sem esta checagem, uma pagina maliciosa aberta na mesma maquina poderia + trocar a senha do painel. + + A ordem importa: o Origin e a evidencia forte e e avaliado primeiro. + Checar Sec-Fetch-Site antes disso fazia um valor inesperado do navegador + recusar a requisicao mesmo com o Origin batendo com o Host. Nao se usa + Referer porque a propria pagina e servida com Referrer-Policy: no-referrer. + """ + host = self.headers.get("Host", "") + origin = self.headers.get("Origin", "") + + if origin and origin != "null": + # Comparacao pelo host declarado: mesma origem, requisicao legitima. + if urlparse(origin).netloc == host: + return True + # Origin presente e divergente e a unica prova positiva de ataque. + return False + + fetch_site = self.headers.get("Sec-Fetch-Site", "") + if fetch_site: + # "none" e a navegacao digitada na barra de enderecos. + return fetch_site in ("same-origin", "none") + + # Cliente que nao e navegador (curl, script): nao ha sessao a sequestrar. + return True + def do_POST(self): if not self.require_auth(): return + if not self.is_same_origin_request(): + # Devolve o usuario para o painel explicando o motivo, em vez de uma + # pagina de erro crua sem caminho de volta. + self.redirect_to_dashboard( + "danger", translate("security.cross_origin", self.resolve_language()) + ) + return + 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_credentials_updated(self): + """Confirma a troca de senha sem exigir a credencial que acabou de mudar.""" + lang = self.resolve_language() + payload = render_notice_page( + translate("auth.updated_title", lang), + translate("auth.updated_body", lang), + translate("auth.updated_link", lang), + ) + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(payload) + def serve_healthz(self): + """Health check do Docker: barato, sem cache do navegador e sem exceção no log. + + A sondagem ao gateway passa por probe_gateway, que memoriza o resultado; + sem isso cada probe pagava até 3s de HTTP de saída e estourava o timeout + do healthcheck, que fechava o socket e gerava BrokenPipeError. + """ db_ok = bool(self.db_path and os.path.exists(self.db_path)) - router_ok = True - if self.omniroute_url: - now = time.time() - if now - OminiDashboardHandler._last_gw_check < 15.0: - router_ok = OminiDashboardHandler._last_gw_ok - else: - 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 - OminiDashboardHandler._last_gw_check = now - OminiDashboardHandler._last_gw_ok = router_ok - - if db_ok and router_ok: - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/plain") - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(b"OK") + gateway_ok = self.probe_gateway() + + if db_ok and gateway_ok: + status, payload = HTTPStatus.OK, b"OK" + elif not db_ok: + status, payload = HTTPStatus.SERVICE_UNAVAILABLE, b"DATABASE_NOT_READY" 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.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(reason.encode("utf-8")) + status, payload = HTTPStatus.SERVICE_UNAVAILABLE, b"GATEWAY_SERVICE_UNREACHABLE" + + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(payload) def serve_status(self): conns = [] @@ -154,7 +319,7 @@ def serve_status(self): cron_info = self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False} is_default = self.settings.is_default_password() if self.settings else False - cur_user, _ = self.settings.get_auth_credentials() if self.settings else ("admin", "pathbit") + cur_user, _ = self.settings.get_auth_credentials() if self.settings else ("admin", "") payload = { "status": "online", @@ -163,7 +328,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") @@ -171,7 +352,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} @@ -180,7 +361,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() @@ -228,7 +409,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") @@ -236,7 +417,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: @@ -244,13 +425,30 @@ def handle_change_password(self, raw_body: bytes): new_user = str(data.get("newUser") or "admin").strip() 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") + # Mesma política de força do formulário da tela. + problems = self.settings.check_password_strength(new_pass) if self.settings else [] + if problems: + lang = self.resolve_language() + detail = " ".join(translate(key, lang) for key in problems) + body = json.dumps({"success": False, "error": detail}).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: @@ -258,24 +456,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: @@ -286,14 +484,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) @@ -306,7 +504,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") @@ -314,568 +512,209 @@ 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("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 invalidate_caches(self) -> None: + """Descarta o que foi memorizado para que a próxima renderização releia tudo. + + Sem isto, o resultado da sondagem ao gateway continuaria valendo por até + 30s e o painel exibiria um estado anterior à ação recém-disparada. + """ + with _gateway_probe_lock: + _gateway_probe_cache.clear() + + 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/atualizar": + # Recarga completa: zera os caches e volta para a página, que é + # montada de novo no servidor a partir do banco. + self.invalidate_caches() + self.redirect_to_dashboard("info", translate("action.refreshed", self.resolve_language())) + 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() + + # A política de força é obrigatória: devolve todas as regras + # violadas de uma vez, no idioma escolhido, em vez de recusar sem + # dizer o motivo. + problems = self.settings.check_password_strength(new_pass) if self.settings else [] + if problems: + lang = self.resolve_language() + self.redirect_to_dashboard( + "danger", " ".join(translate(key, lang) for key in problems) + ) + 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.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/credenciais-atualizadas") + self.send_header("Content-Length", "0") + self.end_headers() + 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( @@ -897,4 +736,3 @@ def start_omini_web( 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..2eddc2a --- /dev/null +++ b/tests/test_auth_recovery.py @@ -0,0 +1,210 @@ +"""Testes da regra de autenticação do dashboard, incluindo a credencial de recuperação.""" + +import json +import os +import stat +import tempfile +import pathlib +import unittest +import unittest.mock + +from omini_rtksync import cli as omini_rtksync_cli + +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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.mock.patch.dict(os.environ, base, clear=True): + return Settings.from_env(env_file=""), dict(base) + + def test_full_lifecycle_from_first_boot_to_change_to_recovery(self): + settings, base = self._settings() + with unittest.mock.patch.dict(os.environ, base, clear=True): + # 1. Primeiro acesso. Nao existe senha de fabrica: um valor estatico + # seria, por definicao, uma credencial publica. Quem abre a porta e a + # credencial sorteada no primeiro boot. + recovery, generated_now = settings.ensure_recovery_hash() + self.assertTrue(generated_now) + self.assertFalse(settings.verify_credentials("admin", "pathbit")) + self.assertFalse(settings.verify_credentials("admin", "")) + self.assertTrue(settings.verify_credentials("admin", recovery)) + + # 2. Operador troca a senha pela tela. + self.assertTrue(settings.update_auth_credentials("operador", "Minha-Senha1")) + self.assertTrue(settings.verify_credentials("operador", "Minha-Senha1")) + 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 unittest.mock.patch.dict(os.environ, base, clear=True): + settings.update_auth_credentials("operador", "Minha-Senha1") + 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 unittest.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() + + +class TestRecoveryHashIsNeverLogged(unittest.TestCase): + """O stdout do container e coletado e encaminhado: credencial funcional nao vai para la.""" + + def test_the_startup_log_does_not_print_the_hash(self): + source = pathlib.Path(omini_rtksync_cli.__file__).read_text(encoding="utf-8") + block = source[source.index("ensure_recovery_hash()"):] + block = block[: block.index("if args.db_path")] + # A chamada de log pode citar o caminho do arquivo, nunca o valor. + self.assertNotIn("recovery_hash,", block.split("logger.warning")[1]) + self.assertIn("get_recovery_file_path()", block) diff --git a/tests/test_cli.py b/tests/test_cli.py index dc35d9a..415529f 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 @@ -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,15 +58,23 @@ 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) - # Should execute without raising exceptions + settings = Settings(db_path=self.db_path, enable_web=False, validate_credentials=False) + # 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..f33e5df 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 +import unittest.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,103 @@ 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 unittest.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_credentials_still_win_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-Tela1")) + # A senha agora vive como hash no SQLite, então o que se verifica é + # a autenticação, não a igualdade do texto. + self.assertTrue(s.verify_credentials("da-tela", "Senha-Tela1")) + self.assertFalse(s.verify_credentials("da-tela", "outra")) + + 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_credential_check.py b/tests/test_credential_check.py new file mode 100644 index 0000000..869e557 --- /dev/null +++ b/tests/test_credential_check.py @@ -0,0 +1,263 @@ +"""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 unittest +import urllib.error + +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_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_database.py b/tests/test_database.py index 152b693..6bc00c6 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 @@ -8,9 +9,9 @@ from omini_rtksync.database import ( detect_connection_table, - get_all_combos, get_all_connections, get_db_connection, + normalize_expiry_format, update_connection, ) @@ -77,9 +78,139 @@ 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.assertTrue("2026-09" in ag["expiresAt"]) - self.assertEqual(ag["testStatus"], "active") + 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__": unittest.main() + + +class TestExpiryFormatHealing(unittest.TestCase): + """A cura de formato nao pode depender de a renovacao OAuth dar certo. + + Um epoch numerico gravado como texto e Invalid Date para o OmniRoute, que + entao conclui que a conexao nao tem validade conhecida e desliga a propria + renovacao preventiva. Se o refresh token estiver revogado, a renovacao falha + e, antes desta correcao, o formato quebrado permanecia para sempre. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db_path = os.path.join(self.tmp.name, "storage.sqlite") + conn = sqlite3.connect(self.db_path) + 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)" + ) + self.epoch_ms = 1789226422362 + conn.execute( + "INSERT INTO provider_connections VALUES " + "('c1','antigravity','Teste','tok','ref',NULL,?, 'ok','','')", + (str(self.epoch_ms),), + ) + conn.commit() + conn.close() + + def stored(self): + conn = sqlite3.connect(self.db_path) + row = conn.execute( + "SELECT expires_at, access_token, refresh_token FROM provider_connections WHERE id='c1'" + ).fetchone() + conn.close() + return row + + def test_the_seeded_value_is_the_broken_shape(self): + self.assertTrue(self.stored()[0].isdigit()) + + def test_normalizing_rewrites_the_expiry_as_iso(self): + self.assertTrue(normalize_expiry_format(self.db_path, "c1", self.epoch_ms)) + expiry = self.stored()[0] + self.assertFalse(expiry.isdigit()) + self.assertTrue(expiry.endswith("Z")) + # Tem de ser relegivel como a mesma instante. + parsed = datetime.fromisoformat(expiry.replace("Z", "+00:00")) + self.assertEqual(int(parsed.timestamp() * 1000), self.epoch_ms) + + def test_normalizing_never_touches_the_tokens(self): + normalize_expiry_format(self.db_path, "c1", self.epoch_ms) + _, access, refresh = self.stored() + self.assertEqual(access, "tok") + self.assertEqual(refresh, "ref") + + def test_an_unknown_connection_is_reported_as_not_written(self): + self.assertFalse(normalize_expiry_format(self.db_path, "nao-existe", self.epoch_ms)) 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_env_documentation.py b/tests/test_env_documentation.py new file mode 100644 index 0000000..ae1a32a --- /dev/null +++ b/tests/test_env_documentation.py @@ -0,0 +1,82 @@ +"""O .env.example precisa cobrir toda variavel que o codigo realmente le. + +Documentacao defasada nao e detalhe: quem implanta copia o exemplo, nao le o +codigo. Uma variavel que o programa consulta e o exemplo nao cita e uma opcao +que so existe para quem leu a fonte -- e foi exatamente o que o usuario +encontrou ao abrir o arquivo. + +Este teste compara os dois conjuntos e falha nomeando a diferenca, entao a +defasagem aparece no CI em vez de aparecer em producao. +""" + +import os +import pathlib +import re +import unittest + +import omini_rtksync + +RAIZ_PACOTE = pathlib.Path(omini_rtksync.__file__).parent +RAIZ_REPO = RAIZ_PACOTE.parent.parent +ENV_EXAMPLE = RAIZ_REPO / ".env.example" + +# Variaveis do gateway, documentadas para a stack do compose e lidas pelo +# container do 9Router -- nao por este programa. +DO_GATEWAY = {"INITIAL_PASSWORD", "JWT_SECRET", "REQUIRE_API_KEY", "REQUIRE_LOGIN"} + +LEITURA = re.compile(r'os\.(?:environ\.get|getenv)\(\s*["\']([A-Z0-9_]+)["\']') +INDICE = re.compile(r'os\.environ\[\s*["\']([A-Z0-9_]+)["\']') +DECLARACAO = re.compile(r'^#?\s*([A-Z0-9_]+)=', re.M) + + +def variaveis_lidas(): + encontradas = set() + for caminho in RAIZ_PACOTE.rglob("*.py"): + texto = caminho.read_text(encoding="utf-8") + encontradas |= set(LEITURA.findall(texto)) + encontradas |= set(INDICE.findall(texto)) + return encontradas + + +def variaveis_documentadas(): + return set(DECLARACAO.findall(ENV_EXAMPLE.read_text(encoding="utf-8"))) + + +class TestDocumentacaoDeAmbiente(unittest.TestCase): + def test_the_example_file_exists(self): + self.assertTrue(ENV_EXAMPLE.is_file(), f"esperado em {ENV_EXAMPLE}") + + def test_every_variable_the_code_reads_is_documented(self): + faltando = sorted(variaveis_lidas() - variaveis_documentadas()) + self.assertEqual( + faltando, [], + "variaveis lidas pelo codigo e ausentes do .env.example: " + ", ".join(faltando), + ) + + def test_the_example_documents_nothing_the_code_ignores(self): + sobrando = sorted(variaveis_documentadas() - variaveis_lidas() - DO_GATEWAY) + self.assertEqual( + sobrando, [], + "variaveis no .env.example que programa nenhum le: " + ", ".join(sobrando), + ) + + def test_the_example_carries_no_credential_value(self): + """Valor publicado em arquivo de exemplo e credencial publica.""" + texto = ENV_EXAMPLE.read_text(encoding="utf-8") + suspeitos = re.findall( + r'^(?!#)\s*([A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|KEY)[A-Z0-9_]*)=(.+)$', + texto, re.M, + ) + # Interruptor nao e segredo: REQUIRE_API_KEY=false diz o que o gateway + # deve fazer, nao qual e a chave. + INTERRUPTORES = {"true", "false", "0", "1", "yes", "no", "on", "off"} + com_valor = [ + f"{nome}={valor.strip()}" + for nome, valor in suspeitos + if valor.strip() and valor.strip().lower() not in INTERRUPTORES + ] + self.assertEqual(com_valor, [], "campo de segredo preenchido no exemplo: " + ", ".join(com_valor)) + + +if __name__ == "__main__": + unittest.main() 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_local_provider.py b/tests/test_local_provider.py new file mode 100644 index 0000000..906582d --- /dev/null +++ b/tests/test_local_provider.py @@ -0,0 +1,98 @@ +"""Tests for local instance discovery (Ollama, vLLM, LM Studio).""" + +import unittest +import unittest.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 unittest.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 unittest.mock.patch.object(LocalProvider, "discover_models", return_value=(["llama3.2:3b"], "")): + renewed, data, messages = self.provider.check_and_refresh(self.conn) + # Sondagem local nao conta como renovacao de credencial; o que prova que + # funcionou e o dicionario devolvido para gravacao. + self.assertFalse(renewed) + self.assertIsNotNone(data) + 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 unittest.mock.patch.object(LocalProvider, "discover_models", return_value=([], "Connection refused")): + renewed, data, messages = self.provider.check_and_refresh(self.conn) + self.assertFalse(renewed) + self.assertIsNotNone(data) + self.assertEqual(data["testStatus"], "unreachable") + self.assertEqual(data["lastError"], "Connection refused") + self.assertIn("did not answer", messages[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..5bf1497 --- /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 +import unittest.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 unittest.mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(logs.get_retention_days(), 30) + + def test_retention_is_configurable(self): + with unittest.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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.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 unittest.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_password_policy.py b/tests/test_password_policy.py new file mode 100644 index 0000000..0275d90 --- /dev/null +++ b/tests/test_password_policy.py @@ -0,0 +1,192 @@ +"""Testes da politica de senha e da credencial gravada em SQLite.""" + +import os +import tempfile +import unittest + +from omini_rtksync.auth import ( + hash_password, + password_matches, + read_db_credentials, + validate_password_strength, + write_db_credentials, +) +from omini_rtksync.config import Settings +from omini_rtksync.prefs import resolve_prefs_path + + +class TestPasswordStrength(unittest.TestCase): + def test_accepts_a_password_meeting_every_rule(self): + self.assertEqual(validate_password_strength("Sample1!"), []) + + def test_reports_every_broken_rule_at_once(self): + """Uma regra por tentativa faria o usuario adivinhar a politica aos poucos.""" + problems = validate_password_strength("abc") + self.assertIn("password.too_short", problems) + self.assertIn("password.needs_upper", problems) + self.assertIn("password.needs_digit", problems) + self.assertIn("password.needs_special", problems) + + def test_six_characters_is_the_floor(self): + self.assertIn("password.too_short", validate_password_strength("Ab1!c")) + self.assertEqual(validate_password_strength("Ab1!cd"), []) + + def test_each_class_is_required(self): + self.assertEqual(validate_password_strength("ABC123!@"), ["password.needs_lower"]) + self.assertEqual(validate_password_strength("abc123!@"), ["password.needs_upper"]) + self.assertEqual(validate_password_strength("Abcdef!@"), ["password.needs_digit"]) + self.assertEqual(validate_password_strength("Abcdef12"), ["password.needs_special"]) + + def test_the_factory_password_would_be_refused_today(self): + self.assertTrue(validate_password_strength("pathbit")) + + +class TestPasswordHashing(unittest.TestCase): + def test_hash_is_salted_so_two_hashes_never_match(self): + self.assertNotEqual(hash_password("Sample1!"), hash_password("Sample1!")) + + def test_the_password_never_appears_in_the_stored_value(self): + self.assertNotIn("Sample1!", hash_password("Sample1!")) + + def test_matching_and_non_matching(self): + stored = hash_password("Sample1!") + self.assertTrue(password_matches(stored, "Sample1!")) + self.assertFalse(password_matches(stored, "Pathbit1")) + self.assertFalse(password_matches(stored, "")) + + def test_legacy_plaintext_still_authenticates(self): + """Quem ja tinha senha no arquivo antigo nao pode ficar trancado do lado de fora.""" + self.assertTrue(password_matches("senha-antiga", "senha-antiga")) + self.assertFalse(password_matches("senha-antiga", "outra")) + + def test_corrupted_stored_value_denies_access(self): + self.assertFalse(password_matches("pbkdf2_sha256$quebrado", "qualquer")) + + +class TestCredentialsInSqlite(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db_path = os.path.join(self.tmp.name, "data.sqlite") + self.settings = Settings(db_path=self.db_path) + + def test_no_password_stored_means_the_banner_shows(self): + self.assertTrue(self.settings.is_default_password()) + self.assertFalse(self.settings.has_stored_password()) + + def test_the_banner_disappears_once_a_password_is_stored(self): + self.assertTrue(self.settings.update_auth_credentials("admin", "Sample1!")) + self.assertTrue(self.settings.has_stored_password()) + self.assertFalse(self.settings.is_default_password()) + + def test_a_weak_password_is_refused_and_changes_nothing(self): + self.assertFalse(self.settings.update_auth_credentials("admin", "fraca")) + self.assertFalse(self.settings.has_stored_password()) + self.assertTrue(self.settings.is_default_password()) + + def test_the_stored_password_authenticates_and_the_old_one_stops(self): + self.settings.update_auth_credentials("admin", "Sample1!") + self.assertTrue(self.settings.verify_credentials("admin", "Sample1!")) + self.assertFalse(self.settings.verify_credentials("admin", "pathbit")) + + def test_the_password_is_never_written_in_clear_text(self): + self.settings.update_auth_credentials("admin", "Sample1!") + prefs = resolve_prefs_path(os.path.dirname(self.db_path)) + with open(prefs, "rb") as handle: + raw = handle.read() + self.assertNotIn(b"Sample1!", raw) + + def test_headless_mode_refuses_to_write(self): + """Com a senha vindo do ambiente, gravar aqui criaria estado que ninguem le.""" + headless = Settings(db_path=self.db_path, dashboard_auth_from_env=True) + self.assertFalse(headless.update_auth_credentials("admin", "Sample1!")) + # E o banner nao aparece: quem opera o ambiente ja controla o segredo. + self.assertFalse(headless.is_default_password()) + + def test_credentials_round_trip_through_the_database(self): + prefs = resolve_prefs_path(self.tmp.name) + self.assertIsNone(read_db_credentials(prefs)) + self.assertTrue(write_db_credentials(prefs, "operador", "Sample1!")) + user, stored = read_db_credentials(prefs) + self.assertEqual(user, "operador") + self.assertTrue(password_matches(stored, "Sample1!")) + + +class TestNoFactoryPassword(unittest.TestCase): + """Uma senha padrao estatica e, por definicao, uma credencial publica.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db_path = os.path.join(self.tmp.name, "data.sqlite") + + def test_the_settings_default_carries_no_password(self): + self.assertEqual(Settings(db_path=self.db_path).dashboard_password, "") + + def test_no_guessable_password_opens_the_panel(self): + s = Settings(db_path=self.db_path) + s.ensure_recovery_hash() + for guess in ("pathbit", "admin", "", "password", "123456", "9rtksync"): + self.assertFalse(s.verify_credentials("admin", guess), guess) + + def test_the_recovery_credential_is_the_only_way_in_before_a_password_is_set(self): + s = Settings(db_path=self.db_path) + recovery, _ = s.ensure_recovery_hash() + self.assertTrue(s.verify_credentials("admin", recovery)) + + def test_the_recovery_credential_is_long_enough_to_resist_guessing(self): + s = Settings(db_path=self.db_path) + recovery, _ = s.ensure_recovery_hash() + self.assertGreaterEqual(len(recovery), 32) + + +class TestEnvManagedAuthNeedsAPassword(unittest.TestCase): + """So a senha decide se a credencial e gerida pelo ambiente. + + O docker-compose de exemplo define DASHBOARD_USER=admin e deixa + DASHBOARD_PASSWORD vazia. Enquanto o nome de usuario tambem contava, isso + marcava a autenticacao como autoritativa do ambiente: o painel recusava + definir senha pela tela ("Credenciais definidas por variavel de ambiente"), + a instalacao ficava presa na credencial de recuperacao e o aviso de + seguranca nunca sumia, porque nunca havia senha gravada no SQLite. + """ + + def setUp(self): + self.original = { + chave: os.environ.get(chave) + for chave in ("DASHBOARD_USER", "DASHBOARD_PASSWORD") + } + self.addCleanup(self.restaurar) + for chave in self.original: + os.environ.pop(chave, None) + + def restaurar(self): + for chave, valor in self.original.items(): + if valor is None: + os.environ.pop(chave, None) + else: + os.environ[chave] = valor + + def test_only_the_user_set_is_not_env_managed(self): + os.environ["DASHBOARD_USER"] = "admin" + self.assertFalse(Settings.from_env().dashboard_auth_from_env) + + def test_the_shape_shipped_in_the_compose_example_is_not_env_managed(self): + # Exatamente o que o docker-compose.example.yml produz. + os.environ["DASHBOARD_USER"] = "admin" + os.environ["DASHBOARD_PASSWORD"] = "" + settings = Settings.from_env() + self.assertFalse(settings.dashboard_auth_from_env) + # E o nome de usuario continua sendo respeitado. + self.assertEqual(settings.dashboard_user, "admin") + + def test_a_real_password_is_env_managed(self): + os.environ["DASHBOARD_PASSWORD"] = "Sample1!" + self.assertTrue(Settings.from_env().dashboard_auth_from_env) + + def test_nothing_set_is_not_env_managed(self): + self.assertFalse(Settings.from_env().dashboard_auth_from_env) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_dispatch.py b/tests/test_provider_dispatch.py new file mode 100644 index 0000000..a3bfcf2 --- /dev/null +++ b/tests/test_provider_dispatch.py @@ -0,0 +1,341 @@ +"""Regressões do despacho de providers, da persistência e da contagem de renovações. + +Cada teste aqui nasceu de um apontamento da revisão automática do PR: + +- uma conexão local carrega chave de fachada, então o handler genérico de chave + a engolia antes do handler local e o catálogo nunca era descoberto; +- o resultado da sondagem era devolvido pelo provider e **descartado** pelo + motor: o painel recarregava a linha antiga e uma chave recusada continuava + verde na tela; +- carimbar o horário de uma verificação virava "credencial renovada"; +- um catálogo vazio era relatado como instância fora do ar; +- 5xx do provedor virava "credencial válida"; +- um `baseUrl` próprio (Azure, proxy) perdia para o casamento por substring. +""" + +import os +import sqlite3 +import tempfile +import unittest +import unittest.mock + +from omini_rtksync.credential_check import ( + STATE_UNREACHABLE, + STATE_VALID, + CheckResult, + _classify, + check_api_key, +) +from omini_rtksync.database import get_all_connections, update_connection_health +from omini_rtksync.providers import ApiKeyProvider, LocalProvider + + +LOCAL = { + "id": "c-local", + "provider": "openai-compatible-ollama", + "name": "Ollama Local", + "hasApiKey": True, + "apiKey": "chave-de-fachada", + "isOAuth": False, + "baseUrl": "http://127.0.0.1:11434/v1", + "providerSpecificData": {"baseUrl": "http://127.0.0.1:11434/v1"}, +} +NUVEM = { + "id": "c-groq", + "provider": "groq", + "name": "Groq", + "hasApiKey": True, + "apiKey": "gsk_algo", + "isOAuth": False, +} + + +class TestLocalNaoEEngolidaPeloHandlerDeChave(unittest.TestCase): + def test_the_api_key_handler_declines_a_local_connection(self): + self.assertFalse(ApiKeyProvider().can_handle(LOCAL)) + + def test_the_local_handler_takes_it(self): + self.assertTrue(LocalProvider().can_handle(LOCAL)) + + def test_a_cloud_key_still_goes_to_the_api_key_handler(self): + self.assertTrue(ApiKeyProvider().can_handle(NUVEM)) + self.assertFalse(LocalProvider().can_handle(NUVEM)) + + +class TestVerificacaoNaoERenovacao(unittest.TestCase): + def test_a_plain_validation_does_not_count_as_a_renewal(self): + provider = ApiKeyProvider(validate_credentials=True) + with unittest.mock.patch( + "omini_rtksync.providers.check_connection", + return_value=CheckResult(state=STATE_VALID, detail="ok", checked_at="2026-01-01T00:00:00Z"), + ): + renewed, data, _ = provider.check_and_refresh(dict(NUVEM)) + self.assertFalse(renewed, "verificar chave nao e renovar chave") + self.assertIsNotNone(data, "mas o resultado tem de ser gravado") + self.assertEqual(data["testStatus"], "active") + + +class TestCatalogoVazioNaoEQueda(unittest.TestCase): + def test_an_instance_answering_with_no_model_is_still_up(self): + with unittest.mock.patch.object(LocalProvider, "discover_models", return_value=([], "")): + _, data, msgs = LocalProvider().check_and_refresh(dict(LOCAL)) + self.assertEqual(data["testStatus"], "active") + self.assertTrue(any("empty model catalog" in m for m in msgs)) + + def test_an_instance_that_does_not_answer_is_unreachable(self): + with unittest.mock.patch.object(LocalProvider, "discover_models", return_value=([], "Connection refused")): + _, data, _ = LocalProvider().check_and_refresh(dict(LOCAL)) + self.assertEqual(data["testStatus"], "unreachable") + + +class TestErroDoProvedorNaoValidaCredencial(unittest.TestCase): + def test_5xx_is_unreachable_not_valid(self): + for status in (500, 502, 503, 504): + with self.subTest(status=status): + self.assertEqual(_classify(status), STATE_UNREACHABLE) + + def test_2xx_is_still_valid(self): + self.assertEqual(_classify(200), STATE_VALID) + + +class TestEnderecoDeclaradoVenceONome(unittest.TestCase): + def urls_sondadas(self, provider, base_url): + vistas = [] + + def espiao(request, timeout, opener=None, spec_invalid=()): + vistas.append(request.full_url) + return CheckResult(state=STATE_VALID, detail="ok", checked_at="2026-01-01T00:00:00Z") + + with unittest.mock.patch("omini_rtksync.credential_check._execute", side_effect=espiao): + check_api_key(provider, "k", base_url=base_url) + return vistas + + def test_an_azure_openai_key_never_reaches_api_openai_com(self): + urls = self.urls_sondadas("azure-openai", "https://minha-org.openai.azure.com/v1") + self.assertNotIn("api.openai.com", urls[0]) + self.assertIn("minha-org.openai.azure.com", urls[0]) + + def test_the_vendor_endpoint_is_still_used_when_no_address_is_declared(self): + urls = self.urls_sondadas("anthropic", None) + self.assertIn("api.anthropic.com", urls[0]) + + +class TestResultadoDaSondagemEGravado(unittest.TestCase): + """O motor descartava o retorno do provider; esta é a prova de que agora grava.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db = os.path.join(self.tmp.name, "storage.sqlite") + con = sqlite3.connect(self.db) + # Recorte do schema real do OmniRoute, com as colunas que importam aqui. + con.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, " + "last_tested TEXT, last_health_check_at TEXT, last_error TEXT, " + "rate_limited_until TEXT, provider_specific_data TEXT, " + "created_at TEXT, updated_at TEXT)" + ) + con.execute( + "INSERT INTO provider_connections (id, provider, name, api_key, test_status) " + "VALUES ('c1','groq','Groq','gsk_x','active')" + ) + con.commit() + con.close() + + def linha(self): + return get_all_connections(self.db)[0] + + def test_a_rejected_key_stops_being_green(self): + self.assertEqual(self.linha()["testStatus"], "active") + update_connection_health( + self.db, "c1", test_status="invalid", credential_state="invalid", last_error="HTTP 401" + ) + atual = self.linha() + self.assertEqual(atual["testStatus"], "invalid") + self.assertEqual(atual["credentialState"], "invalid") + self.assertEqual(atual["lastError"], "HTTP 401") + + def test_the_check_timestamp_feeds_the_last_renewal_column(self): + self.assertIsNone(self.linha()["lastTested"]) + update_connection_health(self.db, "c1", test_status="active") + self.assertIsNotNone(self.linha()["lastTested"]) + + def test_the_local_catalog_is_persisted(self): + update_connection_health(self.db, "c1", discovered_models=["llama3.2:3b", "qwen2.5:7b"]) + self.assertEqual(self.linha()["discoveredModels"], ["llama3.2:3b", "qwen2.5:7b"]) + + def test_writing_health_never_touches_the_credential(self): + update_connection_health(self.db, "c1", test_status="invalid", discovered_models=[]) + con = sqlite3.connect(self.db) + chave = con.execute("SELECT api_key FROM provider_connections WHERE id='c1'").fetchone()[0] + con.close() + self.assertEqual(chave, "gsk_x") + + def test_an_unknown_connection_reports_nothing_written(self): + self.assertFalse(update_connection_health(self.db, "nao-existe", test_status="invalid")) + + +class TestLinhaCruaNaoVaza(unittest.TestCase): + """`raw` devolvia a linha inteira do banco, com os três segredos juntos.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db = os.path.join(self.tmp.name, "storage.sqlite") + con = sqlite3.connect(self.db) + con.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, " + "provider_specific_data TEXT, created_at TEXT, updated_at TEXT)" + ) + con.execute( + "INSERT INTO provider_connections (id, provider, name, access_token, refresh_token, api_key) " + "VALUES ('c1','groq','Groq','ya29.SEGREDO','1//SEGREDO','gsk_SEGREDO')" + ) + con.commit() + con.close() + + def test_the_projection_has_no_raw_row(self): + self.assertNotIn("raw", get_all_connections(self.db)[0]) + + def test_the_base_url_still_resolves_from_provider_specific_data(self): + con = sqlite3.connect(self.db) + con.execute( + "UPDATE provider_connections SET provider_specific_data = ? WHERE id='c1'", + ('{"baseUrl": "http://127.0.0.1:11434/v1"}',), + ) + con.commit() + con.close() + self.assertEqual(get_all_connections(self.db)[0]["baseUrl"], "http://127.0.0.1:11434/v1") + + +if __name__ == "__main__": + unittest.main() + + +class TestSaidaDeRedePorConta(unittest.TestCase): + """Qual endereco de saida cada conta usa — leitura, nunca escrita. + + Compartilhar um unico endereco de saida entre varias contas do mesmo + fornecedor e o estado que mais preocupa o operador, e nada no painel + mostrava isso. O OmniRoute ja modela tudo: os interruptores ficam em + `provider_connections` (`proxy_enabled`, `per_key_proxy_enabled`) e o + vinculo em `proxy_assignments` com `scope='account'`. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db = os.path.join(self.tmp.name, "storage.sqlite") + con = sqlite3.connect(self.db) + con.execute( + "CREATE TABLE provider_connections (" + "id TEXT PRIMARY KEY, provider TEXT, name TEXT, api_key TEXT, " + "test_status TEXT, proxy_enabled INTEGER, per_key_proxy_enabled INTEGER, " + "provider_specific_data TEXT, created_at TEXT, updated_at TEXT)" + ) + con.execute( + "CREATE TABLE proxy_registry (id TEXT PRIMARY KEY, name TEXT, type TEXT, " + "host TEXT, port INTEGER, status TEXT)" + ) + con.execute( + "CREATE TABLE proxy_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "proxy_id TEXT NOT NULL, scope TEXT NOT NULL, scope_id TEXT, " + "position INTEGER NOT NULL DEFAULT 0)" + ) + con.executemany( + "INSERT INTO provider_connections (id, provider, name, api_key, test_status, " + "proxy_enabled, per_key_proxy_enabled) VALUES (?,?,?,?,?,?,?)", + [ + ("acc-1", "anthropic", "Conta A", "k1", "active", 1, 0), + ("acc-2", "anthropic", "Conta B", "k2", "active", 0, 0), + ], + ) + con.execute( + "INSERT INTO proxy_registry VALUES ('p-eu','Saida Frankfurt','http','10.8.0.21',8080,'active')" + ) + con.execute( + "INSERT INTO proxy_assignments (proxy_id, scope, scope_id, position) " + "VALUES ('p-eu','account','acc-1',0)" + ) + con.commit() + con.close() + + def por_id(self): + return {c["id"]: c for c in get_all_connections(self.db)} + + def test_an_account_with_its_own_egress_is_reported_as_bound(self): + conta = self.por_id()["acc-1"] + self.assertEqual(conta["egressProxy"], "Saida Frankfurt") + + def test_an_account_without_a_binding_reports_a_shared_egress(self): + conta = self.por_id()["acc-2"] + self.assertIsNone(conta["egressProxy"]) + + def test_an_assignment_of_another_scope_never_binds_the_account(self): + con = sqlite3.connect(self.db) + con.execute( + "INSERT INTO proxy_assignments (proxy_id, scope, scope_id, position) " + "VALUES ('p-eu','provider','anthropic',0)" + ) + con.commit() + con.close() + # Escopo de provedor nao e vinculo de conta: acc-2 continua compartilhada. + self.assertIsNone(self.por_id()["acc-2"]["egressProxy"]) + + def test_reading_the_binding_writes_nothing(self): + antes = sqlite3.connect(self.db).execute( + "SELECT count(*) FROM proxy_assignments" + ).fetchone()[0] + get_all_connections(self.db) + depois = sqlite3.connect(self.db).execute( + "SELECT count(*) FROM proxy_assignments" + ).fetchone()[0] + self.assertEqual(antes, depois) + + def test_an_older_schema_without_the_proxy_tables_still_lists_connections(self): + con = sqlite3.connect(self.db) + con.execute("DROP TABLE proxy_assignments") + con.execute("DROP TABLE proxy_registry") + con.commit() + con.close() + conexoes = get_all_connections(self.db) + self.assertEqual(len(conexoes), 2) + self.assertIsNone(conexoes[0]["egressProxy"]) + + +class TestOllamaHospedadoNaoELocal(unittest.TestCase): + """O marcador "ollama" também casa com a conta hospedada. + + Tratar `https://ollama.com/v1` como local mandaria o sincronizador sondar um + catálogo que não existe ali, e tiraria a conexão do caminho de validação de + chave — que é justamente onde ela precisa estar. + """ + + HOSPEDADA = { + "id": "c-nuvem", "provider": "ollama", "name": "Ollama Cloud", + "hasApiKey": True, "apiKey": "k", "isOAuth": False, + "baseUrl": "https://ollama.com/v1", + "providerSpecificData": {"baseUrl": "https://ollama.com/v1"}, + } + LOCAL = { + "id": "c-local", "provider": "ollama", "name": "Ollama Local", + "hasApiKey": True, "apiKey": "k", "isOAuth": False, + "baseUrl": "http://127.0.0.1:11434/v1", + "providerSpecificData": {"baseUrl": "http://127.0.0.1:11434/v1"}, + } + + def test_the_hosted_account_goes_to_the_api_key_handler(self): + self.assertFalse(LocalProvider.is_local_connection(self.HOSPEDADA)) + self.assertTrue(ApiKeyProvider().can_handle(self.HOSPEDADA)) + + def test_the_local_instance_still_goes_to_the_local_handler(self): + self.assertTrue(LocalProvider.is_local_connection(self.LOCAL)) + self.assertFalse(ApiKeyProvider().can_handle(self.LOCAL)) + + def test_without_a_declared_address_the_name_still_decides(self): + sem_endereco = {"id": "c", "provider": "ollama", "name": "x", "hasApiKey": True} + self.assertTrue(LocalProvider.is_local_connection(sem_endereco)) diff --git a/tests/test_startup_storage.py b/tests/test_startup_storage.py new file mode 100644 index 0000000..01e2c6b --- /dev/null +++ b/tests/test_startup_storage.py @@ -0,0 +1,65 @@ +"""O armazenamento do painel precisa nascer no startup, dentro do volume de dados. + +Antes disto, quando o diretorio do DB_PATH ainda nao existia -- que e o estado +do primeiro boot, porque quem cria esse diretorio e o gateway -- a resolucao do +caminho caia silenciosamente para $HOME. O banco de preferencias, onde mora a +senha do painel, era gravado fora do volume: a senha sumia ao recriar o +container e o painel voltava a exigir a credencial de recuperacao. +""" + +import os +import tempfile +import unittest +import unittest.mock + +from omini_rtksync.auth import read_db_credentials, write_db_credentials +from omini_rtksync.config import Settings + + +class TestArmazenamentoNoStartup(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + # Caminho que ainda NAO existe, de proposito. + self.data_dir = os.path.join(self.tmp.name, "app", "data", "db") + self.db_path = os.path.join(self.data_dir, "data.sqlite") + self.assertFalse(os.path.exists(self.data_dir)) + + def settings(self): + with unittest.mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("DATA_DIR", None) + return Settings(db_path=self.db_path, validate_credentials=False) + + def test_the_data_directory_is_created_not_bypassed(self): + caminho = self.settings().get_auth_file_path() + self.assertTrue(os.path.isdir(self.data_dir), "o diretorio de dados tem de ser criado") + self.assertEqual(os.path.dirname(caminho), self.data_dir) + + def test_the_preferences_database_lives_beside_the_gateway_database(self): + prefs = self.settings().get_prefs_path() + self.assertEqual(os.path.dirname(prefs), self.data_dir) + self.assertNotEqual( + os.path.dirname(prefs), + os.path.expanduser("~"), + "a senha do painel nao pode ser gravada fora do volume de dados", + ) + + def test_the_password_survives_a_second_resolution(self): + """Simula recriar o container: resolver de novo tem de achar a mesma senha.""" + prefs = self.settings().get_prefs_path() + self.assertTrue(write_db_credentials(prefs, "admin", "Sample1!")) + + # Segunda resolucao, com o diretorio agora existindo. + prefs_de_novo = self.settings().get_prefs_path() + self.assertEqual(prefs, prefs_de_novo) + self.assertIsNotNone(read_db_credentials(prefs_de_novo)) + + def test_a_read_only_path_still_falls_back_instead_of_crashing(self): + settings = Settings(db_path="/proc/nao-pode-criar/data.sqlite", validate_credentials=False) + caminho = settings.get_auth_file_path() + # Nao levanta; cai para o home, que e o unico lugar gravavel que resta. + self.assertTrue(caminho.endswith(".dashboard_auth.json")) + + +if __name__ == "__main__": + unittest.main() 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..4ac0612 --- /dev/null +++ b/tests/test_web_render.py @@ -0,0 +1,333 @@ +"""Testes da renderização server-side do dashboard.""" + +import base64 +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 import i18n +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('action="/acoes/atualizar"', 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("