diff --git a/.env.example b/.env.example index dedd8f5..b0868c9 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,20 @@ DB_PATH=/app/data/db/data.sqlite # Optional custom path for direct Antigravity OAuth credentials # ANTIGRAVITY_TOKEN_PATH=/root/host/.gemini/oauth_creds.json +# Base directory for everything this container writes: the panel preferences +# database, the recovery credential and the log directory. Defaults to the +# directory of DB_PATH. Set it when the gateway database lives somewhere the +# synchronizer should not write to. +# DATA_DIR=/app/data + +# Google OAuth client used to renew Antigravity / Gemini CLI credentials. +# Leave BOTH empty to let the synchronizer read them from the host credential +# file it discovers. Fill them in only for an isolated deployment that has no +# host credentials mounted. These are secrets: they belong in your .env, which +# is never committed, and never in this example file. +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + # ------------------------------------------------------------------------------ # 2. 9Router Gateway Connectivity # ------------------------------------------------------------------------------ @@ -33,9 +47,17 @@ ROUTER_URL=http://127.0.0.1:20128 # Interval in seconds between synchronization passes and cron renewals (default: 300s / 5min) SYNC_INTERVAL=300 -# Margin in seconds before expiration to trigger proactive renewal (default: 900s / 15min) +# Margin in seconds before expiration to trigger proactive renewal (default: 900s / 15min). +# A token is only renewed once its remaining validity drops below this margin. REFRESH_MARGIN=900 +# Dedicated interval for the cron scheduler. Inherits SYNC_INTERVAL when omitted. +# CRON_INTERVAL=300 + +# Enable/disable the automatic scheduler (1=on, 0=off). +# With 0, synchronization only happens on manual trigger (--once or POST /api/sync). +CRON_ENABLED=1 + # Execution module: all, antigravity, oauth, gemini MODULE=all @@ -48,19 +70,59 @@ ENABLE_WEB_DASHBOARD=1 # Network interface for the web dashboard server WEB_HOST=0.0.0.0 -# HTTP port for the 9RTKSync web dashboard (default: 9190) -WEB_PORT=9190 +# INTERNAL port of the web server inside the container (default: 9090). +# It is the same in both synchronizers; what differs is the port published on the +# host (9091 for 9RTKSync, 9092 for OminiRTKSync). +WEB_PORT=9090 -# HTTP Basic Auth credentials for dashboard protection +# HTTP Basic Auth credentials for dashboard protection. # IMPORTANT: Update these credentials upon first login via web interface or via .env! +# +# Headless mode: when DASHBOARD_USER and/or DASHBOARD_PASSWORD are set, they become +# the source of truth and the .dashboard_auth.json file written by the screen is +# ignored. Changing the password from the panel then answers 409 Conflict. Comment +# the two lines below to hand control back to the dashboard. DASHBOARD_USER=admin -DASHBOARD_PASSWORD=pathbit +# DASHBOARD_PASSWORD= # vazio: usa a credencial de recuperacao do primeiro boot + +# Break-glass recovery credential. Sign in with user 'admin' and this value as the +# password to regain access if the dashboard password is forgotten. When left empty, +# a random value is generated on first boot, stored in .dashboard_recovery (mode +# 0600) and written once to the log file. +# DASHBOARD_RECOVERY_HASH= + +# ------------------------------------------------------------------------------ +# Persistent file log +# ------------------------------------------------------------------------------ +# Directory for log files. Default: /logs. +LOG_DIR=/app/data/logs + +# Retention in days before rotated files are purged (default: 30). +LOG_RETENTION_DAYS=30 + +# Minimum level recorded: DEBUG, INFO, WARNING or ERROR (default: INFO). +LOG_LEVEL=INFO + +# Mirror events on the container stdout as well (1=yes, 0=no). +LOG_TO_STDOUT=1 # ------------------------------------------------------------------------------ # 5. 9Router Stack Integration (Docker Compose) # ------------------------------------------------------------------------------ -# Initial credentials and JWT secret for 9Router -INITIAL_PASSWORD=PathbitDevs2026! -JWT_SECRET=9router-jwt-secret-key-pathbit +# Initial credentials and JWT secret for 9Router. +# Both are REQUIRED and intentionally shipped empty: a value published in an +# example file is a public credential, and it becomes the real password of +# every deployment that copied the file. `docker compose up` refuses to start +# until you fill them in. +# INITIAL_PASSWORD -> at least 12 characters, generated, not typed +# 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/workflows/cleanup-packages.yml b/.github/workflows/cleanup-packages.yml index 05aeff3..8edf62a 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/9rtksyncatest 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-9rtksync + 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: '9rtksync' - 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: 9rtksync + # 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/9rtksync || true + echo "### Versoes mantidas em 9rtksync" >> "$GITHUB_STEP_SUMMARY" + gh api "/orgs/${{ github.repository_owner }}/packages/container/9rtksync/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 114ca0d..d1b95af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENV DB_PATH=/app/data/db/data.sqlite ENV ROUTER_URL=http://127.0.0.1:20128 ENV SYNC_INTERVAL=300 ENV REFRESH_MARGIN=900 -ENV WEB_PORT=9190 +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 9190 +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:9190/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", "nine_rtksync"] CMD ["--daemon"] diff --git a/Makefile b/Makefile index bd2f36a..b4e55ca 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ docker-build: docker build -t 9rtksync:latest -t ghcr.io/pathbit/9rtksync:latest . docker-run: - docker run --rm -it --name router-sync -p 9190:9190 9rtksync:latest + docker run --rm -it --name 9rtksync -p 9091:9090 9rtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/README.md b/README.md index 13af260..555119c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ **`9RTKSync`** (*9Router Universal Token & Connection Synchronizer*) is a high-availability self-healing guardian for [9Router](https://github.com/decolua/9router) gateways. It eliminates sudden disconnects, premature OAuth token expirations, date format corruptions, and lingering rate-limit locks, keeping all connected accounts healthy and persistent. + +## 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. + --- ## Core Features @@ -21,7 +31,7 @@ * **Rate-Limit Lock Clearing** * Automatically purges expired `rateLimitedUntil` locks and resets backoff counters as soon as cooldown periods finish. * **Built-in Web Dashboard** - * Lightweight web server on port `9190` featuring a modern interface, live account countdowns, real-time gateway health diagnostics, and manual sync triggers. + * Lightweight web server on port `9090` (published on `9091`) featuring a modern interface, live account countdowns, real-time gateway health diagnostics, and manual sync triggers. * **Resilience Combos Enforcement** * Keeps fallback combos registered and synchronized in SQLite (`arsenal-supremo`, `arsenal-rapido`, `arsenal-offline`, `claudegravity-fallback`, `claudegravity-thinking`) without primary key conflicts. * **Strict Virtual Environment Execution** @@ -39,7 +49,7 @@ docker pull ghcr.io/pathbit/9rtksync:latest ### Docker Compose Example -Add `router-sync` to your `docker-compose.yml` alongside [9Router](https://github.com/decolua/9router): +Add `9rtksync` to your `docker-compose.yml` alongside [9Router](https://github.com/decolua/9router): ```yaml services: @@ -60,10 +70,10 @@ services: 9rtksync: image: ghcr.io/pathbit/9rtksync:latest - container_name: router-sync + container_name: 9rtksync restart: unless-stopped ports: - - "127.0.0.1:9190:9190" + - "127.0.0.1:9091:9090" volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro @@ -74,14 +84,14 @@ services: - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - - WEB_PORT=${WEB_PORT:-9190} + - WEB_PORT=${WEB_PORT:-9090} - DASHBOARD_USER=${DASHBOARD_USER:-admin} - - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-pathbit} + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} depends_on: 9router: condition: service_healthy healthcheck: - test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9190/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 @@ -130,7 +140,7 @@ cp .env.example .env # Run an immediate one-shot synchronization pass 9RTKSync --once --db-path /path/to/data.sqlite -# Run continuous background daemon with web dashboard on port 9190 +# Run continuous background daemon with web dashboard on port 9090 (published on 9091) 9RTKSync --daemon --db-path /path/to/data.sqlite ``` @@ -145,10 +155,10 @@ cp .env.example .env | `SYNC_INTERVAL` | `300` | Sync and background cron loop interval in seconds | | `REFRESH_MARGIN` | `900` | Proactive token renewal margin in seconds before expiration | | `ENABLE_WEB_DASHBOARD` | `1` | Enable the embedded web dashboard (`1` to enable, `0` to disable) | -| `WEB_PORT` | `9190` | HTTP port for the web dashboard | +| `WEB_PORT` | `9090` | HTTP port for the web dashboard | | `WEB_HOST` | `0.0.0.0` | Network binding interface for the dashboard web server | | `DASHBOARD_USER` | `admin` | HTTP Basic Auth username for web dashboard access | -| `DASHBOARD_PASSWORD` | `pathbit` | Default HTTP Basic Auth password for web dashboard access | +| `DASHBOARD_PASSWORD` | *(vazio)* | Panel password. Left empty, the first sign-in uses the recovery credential generated on first boot. | | `ANTIGRAVITY_TOKEN_PATH` | auto | Custom path for Antigravity OAuth token file | --- @@ -157,7 +167,7 @@ cp .env.example .env When running with `ENABLE_WEB_DASHBOARD=1`, access the dashboard in your browser: -👉 **http://localhost:9190** +👉 **http://localhost:9091** Dashboard capabilities: * Live operational metrics (Total Connections, OAuth Accounts, API Keys, Resilience Combos). diff --git a/docker-compose.example.yml b/docker-compose.example.yml index f3c9dc5..761485e 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -13,33 +13,63 @@ 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:-9router-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: - 9router_data:/app/data 9rtksync: image: ghcr.io/pathbit/9rtksync:latest - container_name: router-sync + container_name: 9rtksync restart: unless-stopped ports: - - "127.0.0.1:9190:9190" + # Porta interna 9090 (igual no OminiRTKSync); publicada em 9091 no host. + # O bind em 127.0.0.1 mantem o painel e o SQLite fora da internet. + - "127.0.0.1:9091:9090" volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro + - 9rtksync_logs:/app/data/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/db/data.sqlite + - ROUTER_URL=${ROUTER_URL:-http://9router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} + - CRON_ENABLED=${CRON_ENABLED:-1} + # - CRON_INTERVAL=300 # herda SYNC_INTERVAL quando omitido - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - - WEB_PORT=${WEB_PORT:-9190} + - 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= + # 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: - 9router + 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: 9router_data: + 9rtksync_logs: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 038a9b5..0ffbecb 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,12 +1,79 @@ -name: 9rtksync-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: 9rtksync-test services: - test: - image: python:3.14-alpine - container_name: 9rtksync-test-runner + 9router: + image: decolua/9router:latest + container_name: 9rtksync-test-gateway + restart: unless-stopped + ports: + - "127.0.0.1:19128: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 + + 9rtksync: + build: . + image: ghcr.io/pathbit/9rtksync:local + container_name: 9rtksync-test-sync + restart: unless-stopped + ports: + # Porta interna 9090 nos tres sincronizadores; publicada em 19091 aqui. + - "127.0.0.1:19091:9090" environment: - - PYTHONPATH=/app/src - command: ["python3", "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"] + - DATA_DIR=/app/data/db + - DB_PATH=/app/data/db/data.sqlite + - ROUTER_URL=http://9router: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: + 9router: + 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..4c08c2d --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,122 @@ +# Architecture + +9RTKSync is a sidecar. It shares the 9Router 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 │──►│ 9RTKSync │◄─────────────────►│ data.sqlite │ + │ :9091 │ │ :9090 │ │ providerConn │ + └────────────┘ └─────┬─────┘ └──────▲───────┘ + │ HTTP probe │ + ▼ │ + ┌───────────┐ │ + │ 9Router │──────────────────────────┘ + │ :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 `providerConnections` 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/server.py` | HTTP server, routing, actions. | +| `web/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: + +- **`expiresAt`.** 9Router keeps connection state inside a JSON `data` column, so a number stays + a number across the round-trip. 9RTKSync writes a numeric epoch in milliseconds, which every + 9Router reader handles. +- **`testStatus`.** `"ok"` and `"active"` both read as healthy; `"active"` is what triggers the + gateway's own health-state reset. + +The sibling project targets a relational schema where the same field is a TEXT column, and the +rules are different. 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/9rtksync.log` | Rotating persistent log. | diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md new file mode 100644 index 0000000..f4d837a --- /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/nine_rtksync/auth.py`](https://github.com/pathbit/9RTKSync/blob/master/src/nine_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 9rtksync 2>&1 | grep "Recovery hash" +docker exec 9rtksync 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:9091: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 9091 directly. diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..a00e567 --- /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 9Router SQLite file. When unset, the first existing candidate wins: `/app/data/db/data.sqlite`, `/app/data/data.sqlite`, `/app/data/storage.sqlite`, `~/.9router/data/db/data.sqlite`, `~/.9router/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 | +| :--- | :--- | :--- | +| `ROUTER_URL` | `http://127.0.0.1:20128` | Base URL of the 9Router 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 (`9091` here, `9092` for OminiRTKSync). | + +--- + +## 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 `~/.9rtksync/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 +9RTKSync --status --db-path /path/to/data.sqlite +9RTKSync --once --db-path /path/to/data.sqlite +9RTKSync --daemon --db-path /path/to/data.sqlite --interval 60 --margin 1200 --port 9090 +9RTKSync --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/db/data.sqlite + - ROUTER_URL=http://9router: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..7f6d32e --- /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:9091** (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 `ROUTER_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..9308769 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,70 @@ +# 9RTKSync + +**9Router Universal Token & Connection Synchronizer** — a high-availability guardian for +[9Router](https://github.com/decolua/9router) 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/9RTKSync/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 9Router 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.** 9Router stores `expiresAt` inside a JSON `data` column. When a +value arrives in a shape the gateway's parser rejects, its proactive refresh silently stops +firing for that connection and the account 401s until someone re-authenticates by hand. +9RTKSync normalizes those values on every sweep. + +**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 `9091`), 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 [OmniRoute](https://github.com/diegosouzapw/OmniRoute) instead of 9Router, use +[OminiRTKSync](https://github.com/pathbit/OminiRTkSync), which targets that gateway's relational +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/9RTKSync/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..dbdb38d --- /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/9rtksync:latest +``` + +A working `docker-compose.yml` alongside the gateway: + +```yaml +name: 9router-stack + +services: + 9router: + image: decolua/9router:latest + container_name: 9router + restart: unless-stopped + ports: + - "127.0.0.1:20128:20128" + environment: + - DATA_DIR=/app/data + - PORT=20128 + - HOSTNAME=0.0.0.0 + volumes: + - 9router_data:/app/data + + 9rtksync: + image: ghcr.io/pathbit/9rtksync:latest + container_name: 9rtksync + restart: unless-stopped + ports: + # Internal port 9090 (same in OminiRTKSync); published on 9091. + # The 127.0.0.1 bind keeps the panel and the SQLite file off the internet. + - "127.0.0.1:9091:9090" + volumes: + - 9router_data:/app/data + - ${HOME}:/root/host:ro + - 9rtksync_logs:/app/data/logs + environment: + - HOST_HOME=/root/host + - DB_PATH=/app/data/db/data.sqlite + - ROUTER_URL=http://9router: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: + - 9router + 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: + 9router_data: + 9rtksync_logs: +``` + +Then open **http://localhost:9091**. + +### Why these details matter + +- **`9router_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/9RTKSync.git +cd 9RTKSync + +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -e . +``` + +### Commands + +```bash +# Connection and combo status, no changes written +9RTKSync --status --db-path ~/.9router/data/db/data.sqlite + +# One immediate synchronization pass +9RTKSync --once --db-path ~/.9router/data/db/data.sqlite + +# Continuous daemon with the dashboard +9RTKSync --daemon --db-path ~/.9router/data/db/data.sqlite + +# Daemon without the web server +9RTKSync --daemon --no-web +``` + +`--db-path` is optional: without it the synchronizer probes the usual locations. See +[Configuration](Configuration). + +--- + +## Running the tests + +```bash +source .venv/bin/activate +PYTHONPATH=src python3 -m unittest discover -s tests -p "test_*.py" +``` + +Or with no local install at all: + +```bash +./run_tests.sh +``` + +--- + +## Upgrading + +```bash +docker compose pull 9rtksync +docker compose up -d 9rtksync +``` + +State that survives upgrades lives in the data volume: `.dashboard_auth.json` (screen-set +credentials), `.dashboard_recovery` (break-glass hash) and `ui_prefs.sqlite` (interface +language). None of them are stored in the gateway's own database. diff --git a/docs/wiki/Logging.md b/docs/wiki/Logging.md new file mode 100644 index 0000000..77c824e --- /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/nine_rtksync/logs.py`](https://github.com/pathbit/9RTKSync/blob/master/src/nine_rtksync/logs.py), +covered by `tests/test_logs.py`. + +--- + +## Configuration + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LOG_DIR` | `/logs` | Destination directory. Falls back to `~/.9rtksync/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, `9rtksync.log`, rotated at **UTC midnight**. +- Rotated files are named `9rtksync.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 `9rtksync.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/ + 9rtksync.log ← active, never purged + 9rtksync.log.2026-09-11 ← kept (2 days old) + 9rtksync.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: + - 9rtksync_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..04423dc --- /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/nine_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/9rtksync: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 + 9Router 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. | +| `ROUTER_SERVICE_UNREACHABLE` | `ROUTER_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 9rtksync 2>&1 | grep "Recovery hash" +# or, if the log file is mounted: +grep "Recovery hash" /app/data/logs/9rtksync.log +``` + +If the log has already rotated past it, the value is on disk: + +```bash +docker exec 9rtksync 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..f43d357 --- /dev/null +++ b/docs/wiki/Upstream-Fixes.md @@ -0,0 +1,99 @@ +# 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 + +[OminiRTKSync](https://github.com/pathbit/OminiRTkSync) 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 this 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 9RTKSync performs 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..303a895 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,15 @@ +### 9RTKSync + +- [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/9RTKSync/tree/master/docs/wiki) diff --git a/src/nine_rtksync/auth.py b/src/nine_rtksync/auth.py new file mode 100644 index 0000000..42c0d51 --- /dev/null +++ b/src/nine_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/nine_rtksync/cli.py b/src/nine_rtksync/cli.py index 6696d4e..e3c98a5 100644 --- a/src/nine_rtksync/cli.py +++ b/src/nine_rtksync/cli.py @@ -6,6 +6,7 @@ from .config import Settings from .daemon import SyncEngine, run_daemon from .database import get_all_combos, get_all_connections +from .logs import setup_logging from .web.server import start_web_server @@ -15,15 +16,15 @@ def print_status_table(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"[ERROR] Error querying SQLite database ({settings.db_path}): {e}", file=sys.stderr) sys.exit(1) print("\n" + "=" * 76) - print("⚡ 9RTKSYNC · 9ROUTER CONNECTIONS AND COMBOS STATUS") + print("[*] 9RTKSYNC · 9ROUTER CONNECTIONS AND COMBOS STATUS") print(f" Database: {settings.db_path}") print("=" * 76) - print(f"\n🔌 Registered Connections ({len(conns)}):") + print(f"\n[*] Registered Connections ({len(conns)}):") print(f" {'PROVIDER':<16} {'NAME':<26} {'TYPE':<10} {'STATUS':<10} {'VALIDITY':<14}") print(" " + "-" * 74) @@ -38,12 +39,14 @@ def print_status_table(settings: Settings): else: val_str = f"{rem // 60} min ({rem}s)" else: - val_str = "Unlimited" + # Chave de API nao carrega validade; dizer "ilimitada" seria + # uma afirmacao que nada no dado sustenta. + val_str = "Not applicable" - status_icon = "✅" if c.health_status in ("active", "no_expiration") else ("⚠️" if c.health_status == "expirando_em_breve" or c.health_status == "expiring_soon" else "❌") + status_icon = "[ok]" if c.health_status in ("active", "no_expiration") else ("[!]" if c.health_status == "expirando_em_breve" or c.health_status == "expiring_soon" else "[ERROR]") print(f" {c.provider:<16} {c.name[:25]:<26} {tipo:<10} {status_icon} {c.health_status:<7} {val_str:<14}") - print(f"\n🔀 Resilience & Fallback Combos ({len(combos)}):") + print(f"\n[*] Resilience & Fallback Combos ({len(combos)}):") print(f" {'COMBO NAME':<26} {'TYPE':<12} {'CASCADE MODELS'}") print(" " + "-" * 74) @@ -97,7 +100,7 @@ def main(): parser.add_argument( "--port", type=int, - help="Port for embedded web server (default: 9190)", + help="Port for embedded web server (default: 9090)", ) parser.add_argument( "--user", @@ -107,22 +110,48 @@ def main(): parser.add_argument( "--password", type=str, - help="Password for web dashboard authentication (default: pathbit)", + help="Password for the web dashboard (no factory default; set it on the screen)", ) args = parser.parse_args() settings = Settings.from_env() + # As opcoes de linha de comando sao aplicadas ANTES de qualquer estado + # persistente ser resolvido. Com --db-path apontando para outro lugar, o log + # e a credencial de recuperacao nasciam ao lado do banco antigo, e a + # autenticacao depois procurava o arquivo ao lado do banco novo: a + # credencial de emergencia gerada e anunciada nunca abria o painel. if args.db_path: settings.db_path = args.db_path if args.interval: settings.sync_interval = args.interval + # O agendador le cron_interval, que ja foi derivado do ambiente antes de + # as opcoes chegarem aqui. Sem esta linha, --interval 60 aparecia no + # banner de inicializacao e o cron seguia no intervalo antigo. + settings.cron_interval = args.interval + if args.port: + settings.web_port = args.port + + logger = setup_logging(settings.db_path) + + # Break-glass credential: generated once so the operator can get back into the + # panel after forgetting the password set on the screen. + # + # 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] Recovery credential generated for user 'admin'. Read it with: " + "docker exec cat %s (or pin your own with DASHBOARD_RECOVERY_HASH)", + settings.get_recovery_file_path(), + ) + if args.margin: settings.refresh_margin = args.margin if args.no_web: settings.enable_web = False - if args.port: - settings.web_port = args.port if args.user: settings.dashboard_user = args.user if args.password: diff --git a/src/nine_rtksync/config.py b/src/nine_rtksync/config.py index 42dffc6..3445e9c 100644 --- a/src/nine_rtksync/config.py +++ b/src/nine_rtksync/config.py @@ -3,7 +3,18 @@ import os import sys 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 _is_truthy(val: str) -> bool: @@ -40,25 +51,99 @@ class Settings: enable_web: bool = True host_home: str = "" web_host: str = "0.0.0.0" - web_port: int = 9190 + web_port: int = 9090 router_url: str = "http://127.0.0.1:20128" module: str = "all" 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 abrir o painel. + 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: """Return the filesystem path for persisted dashboard credentials.""" 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]: - """Get active dashboard credentials (persisted file -> env -> defaults).""" + """Get active dashboard credentials (explicit env -> persisted file -> defaults).""" + # Explicit env wins over the file: without this, a single password change + # through the screen would leave DASHBOARD_USER/DASHBOARD_PASSWORD inert forever. + 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: @@ -74,24 +159,48 @@ def get_auth_credentials(self) -> tuple[str, str]: return self.dashboard_user, self.dashboard_password def is_default_password(self) -> bool: - """Check if the active password is still the factory default (pathbit).""" - _, p = self.get_auth_credentials() - return p == "pathbit" + """Se o painel ainda roda sem senha propria. + + O aviso de seguranca depende disto: ele some assim que existe uma senha + gravada no SQLite, e nao pela comparacao com um texto fixo qualquer. + """ + return not self.has_stored_password() + + def check_password_strength(self, new_pass: str) -> list: + """Chaves de traducao das regras de senha que o valor nao cumpre.""" + return validate_password_strength(new_pass) def update_auth_credentials(self, user: str, new_pass: str) -> bool: - """Save new dashboard credentials to the secure credentials file.""" - 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 politica de forca e obrigatoria. Em modo headless + o ambiente e imutavel 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 razao 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) @@ -132,9 +241,23 @@ 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") + validate_on = os.environ.get("CREDENTIAL_CHECK_ENABLED", "1") not in ("0", "false", "no") return cls( db_path=db_path, @@ -143,11 +266,15 @@ 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", "9190")), + web_port=int(os.environ.get("WEB_PORT", "9090")), router_url=os.environ.get("ROUTER_URL", "http://127.0.0.1:20128"), module=os.environ.get("MODULE", "all"), 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=validate_on, + validation_timeout=float(os.environ.get("CREDENTIAL_CHECK_TIMEOUT", "8")), + dashboard_auth_from_env=auth_from_env, ) diff --git a/src/nine_rtksync/credential_check.py b/src/nine_rtksync/credential_check.py new file mode 100644 index 0000000..b92b02b --- /dev/null +++ b/src/nine_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 = "9RTKSync-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/nine_rtksync/cron.py b/src/nine_rtksync/cron.py index 99d45f5..332a139 100644 --- a/src/nine_rtksync/cron.py +++ b/src/nine_rtksync/cron.py @@ -5,6 +5,32 @@ 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]: + """Extract the actions the sync engine recorded during this cycle. + + Keeps only what explains the outcome — error, renewal, self-healing. A cycle + with nothing to do returns an empty list, and the screen shows it as such. + """ + if not isinstance(res, dict): + return [f"Unexpected engine result: {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.""" @@ -75,7 +101,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...", flush=True) + get_logger().info(f"[CRON] Cycle triggered ({reason}). Inspecting OAuth account connections...") try: res = self.sync_callback() @@ -94,6 +120,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: @@ -106,10 +133,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] Cycle completed in {duration_ms}ms: {total} accounts evaluated, " + f"{refreshed} renewed via OAuth." ) return entry diff --git a/src/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index 154884e..c9bfd71 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -2,6 +2,7 @@ import os import signal +import threading import sys import time from datetime import datetime, timezone @@ -12,15 +13,35 @@ from .cron import CronScheduler from .database import get_all_connections, update_connection_data from .discovery import HostDiscoveryEngine +from .logs import get_logger from .models import ConnectionRecord from .normalizer import normalize_connection_data from .providers import ApiKeyProvider, BaseProvider, GenericOAuthProvider, GoogleProvider, LocalProvider from .web.server import start_web_server +# Prefixes that describe a failure. Emitting everything at INFO meant that +# LOG_LEVEL=WARNING hid exactly the events the persistent log exists for: +# raising the level to cut noise silently dropped every failure. +ERROR_PREFIXES = {"FAILURE", "ERROR", "FALHA", "ERRO"} +WARNING_PREFIXES = {"WARNING", "WARN", "AVISO"} + + def log_msg(prefix: str, text: str): - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{ts}] [{prefix}] {text}", flush=True) + """Record an event in the persistent log (and on stdout, if LOG_TO_STDOUT allows). + + The level follows the prefix: failures go out as ERROR, warnings as WARNING, + everything else as INFO. + """ + logger = get_logger() + message = f"[{prefix}] {text}" + marker = str(prefix).upper() + if marker in ERROR_PREFIXES: + logger.error(message) + elif marker in WARNING_PREFIXES: + logger.warning(message) + else: + logger.info(message) class SyncEngine: @@ -28,6 +49,12 @@ class SyncEngine: 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. Duas passagens simultaneas leem e + # regravam o mesmo JSON de conexao de forma independente: duas renovacoes + # OAuth concorrentes podem sobrescrever um token recem-rotacionado. Uma + # execucao por vez. + self._sync_lock = threading.Lock() self.discovery = HostDiscoveryEngine( host_home=settings.host_home, extra_paths=settings.credential_paths, @@ -35,12 +62,25 @@ def __init__(self, settings: Settings): self.providers: List[BaseProvider] = [ GoogleProvider(credential_paths=settings.credential_paths, discovery=self.discovery), GenericOAuthProvider(discovery=self.discovery), - ApiKeyProvider(discovery=self.discovery), + ApiKeyProvider( + discovery=self.discovery, + validate_credentials=settings.validate_credentials, + validation_timeout=settings.validation_timeout, + ), LocalProvider(), ] def sync_all(self) -> Dict[str, Any]: - """Execute a full synchronization cycle across all registered accounts.""" + """Execute a full synchronization cycle across all registered accounts. + + Serialized: a manual run from the dashboard and a scheduled run must + never overlap, or two concurrent OAuth renewals can overwrite each + other's freshly rotated token. + """ + with self._sync_lock: + return self._sync_all_locked() + + def _sync_all_locked(self) -> Dict[str, Any]: if not os.path.exists(self.settings.db_path): log_msg("ERROR", f"SQLite database not found at: {self.settings.db_path}") return {"success": False, "error": "db_not_found"} @@ -103,12 +143,26 @@ def sync_all(self) -> Dict[str, Any]: log_msg("STATUS", f"[{conn.provider} · {conn.name}] {note}") conn_detail["actions"].append(note) - if renewed and refreshed_data: - summary["refreshed"] += 1 + # Gravar e contar sao decisoes separadas. Sondagem de + # chave e de instancia local muda a linha (horario da + # verificacao, catalogo, estado) sem renovar nada: se o + # mesmo booleano decidisse os dois, ou o painel relia + # linha velha, ou o ciclo anunciava renovacao que nao + # houve. + if refreshed_data: update_connection_data(self.settings.db_path, conn.id, refreshed_data) + if renewed: + summary["refreshed"] += 1 log_msg("SUCCESS", f"[{conn.provider} · {conn.name}] Credentials updated successfully in SQLite") except Exception as e: log_msg("FAILURE", f"[{conn.provider} · {conn.name}] Provider error: {e}") + # Sem isto a excecao some do resumo: o agendador marca o + # ciclo como bem-sucedido e o historico da tela nao + # mostra a falha que acabou de acontecer. + summary.setdefault("errors", []).append( + f"[{conn.provider} · {conn.name}] {e}" + ) + conn_detail["actions"].append(f"Provider error: {e}") break if not handled: @@ -116,6 +170,10 @@ def sync_all(self) -> Dict[str, Any]: summary["details"].append(conn_detail) + # O agendador deriva o resultado do ciclo de summary["success"]. Sem + # isto, um provider que levantou excecao aparecia no historico e o ciclo + # ainda era anunciado como bem-sucedido. + summary["success"] = not summary.get("errors") return summary @@ -133,7 +191,7 @@ def handle_signal(sig, frame): signal.signal(signal.SIGTERM, handle_signal) print("=" * 70, flush=True) - print("⚡ 9RTKSYNC · 9ROUTER UNIVERSAL TOKEN & CONNECTION SYNCHRONIZER", flush=True) + print("[*] 9RTKSYNC · 9ROUTER UNIVERSAL TOKEN & CONNECTION SYNCHRONIZER", flush=True) print(f" SQLite DB: {settings.db_path}", flush=True) print(f" Gateway URL: {settings.router_url}", flush=True) print(f" Host Home: {engine.discovery.host_home}", flush=True) @@ -153,7 +211,7 @@ def handle_signal(sig, frame): # Initialize dedicated CronScheduler for continuous OAuth renewal cron_scheduler = CronScheduler( sync_callback=engine.sync_all, - interval_seconds=settings.sync_interval, + interval_seconds=settings.cron_interval, name="9RTKSync-CronScheduler", ) @@ -169,12 +227,15 @@ 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"[*] Web Dashboard active at: 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"[!] Could not start web dashboard on port {settings.web_port}: {e}", flush=True) # Start background scheduler - cron_scheduler.start() + if settings.cron_enabled: + cron_scheduler.start() + else: + print("[*] Automatic scheduler disabled (CRON_ENABLED=0); use manual trigger.", flush=True) while running: time.sleep(1) diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py new file mode 100644 index 0000000..8404d3c --- /dev/null +++ b/src/nine_rtksync/i18n.py @@ -0,0 +1,371 @@ +"""Internacionalização da interface do 9RTKSync. + +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": "9Router 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": "9Router 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": "9Router 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/nine_rtksync/logs.py b/src/nine_rtksync/logs.py new file mode 100644 index 0000000..2325f23 --- /dev/null +++ b/src/nine_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 ~/.9rtksync/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 = "9rtksync.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("~"), ".9rtksync", "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("9rtksync") + 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/nine_rtksync/models.py b/src/nine_rtksync/models.py index 16f6f68..4877771 100644 --- a/src/nine_rtksync/models.py +++ b/src/nine_rtksync/models.py @@ -46,6 +46,79 @@ def refresh_token(self) -> Optional[str]: def api_key(self) -> Optional[str]: return self.data.get("apiKey") + # Provider names that suggest a local / OpenAI-compatible instance. A marker + # alone is not proof: "ollama" is also the name of Ollama Cloud, which is a + # hosted service and 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") + + @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 self.LOCAL_HOSTS) + + # No address: "openai-compatible" only exists as a self-hosted endpoint, + # whereas "ollama" without a baseUrl is the cloud account. + return "openai-compatible" in self.provider.lower() + + @property + def base_url(self) -> Optional[str]: + """Provider base URL, when declared. + + 9Router keeps it inside providerSpecificData, not at the root of data -- + reading only the root is why local instances used to show no models. + """ + 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("baseURL") or None + + @property + def local_models(self) -> list: + """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_binding(self) -> Optional[str]: + """Proxy pool this connection egresses through, when one is bound. + + Read-only: the binding is owned by the gateway, and 9Router keeps it in + ``providerSpecificData`` as ``proxyPoolId`` plus the per-connection + ``connectionProxyEnabled`` switch. It is surfaced here because an + account that shares one outbound address with every other account is + the state operators most want to notice, and nothing in the panel used + to show it. + """ + specific = self.data.get("providerSpecificData") + if not isinstance(specific, dict): + return None + if specific.get("connectionProxyEnabled") is not True: + return None + pool = specific.get("proxyPoolId") + return str(pool) if pool else None + + @property + def egress_status(self) -> str: + """One of: ``bound`` (own pool), ``shared`` (gateway default), ``unknown``.""" + specific = self.data.get("providerSpecificData") + if not isinstance(specific, dict): + return "unknown" + if specific.get("connectionProxyEnabled") is True and specific.get("proxyPoolId"): + return "bound" + return "shared" + @property def expires_at_ms(self) -> Optional[int]: """Return normalized expiration timestamp in epoch milliseconds, if applicable.""" @@ -72,9 +145,47 @@ def is_expired(self) -> bool: rem = self.remaining_seconds return rem is not None and rem <= 0 + @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]: + """Result of the last live credential probe, when one was recorded. + + Written by credential_check.py, never by the gateway. + """ + state = self.data.get("credentialState") + return str(state) if state else None + @property def health_status(self) -> str: - """Semantic classification of connection health.""" + """Semantic classification of connection health. + + A live probe outranks everything else: a key the provider rejects is + broken no matter what the gateway last stamped. Local instances are + classified before the API-key branch because they carry a facade key + and would otherwise never reach their own test. + """ + probed = self.credential_state + if probed in ("invalid", "rate_limited", "unreachable"): + return probed + + if self.is_local: + # A local instance is only healthy when its model catalog answered. + return "unknown" if self.data.get("testStatus") == "unreachable" else "active" + if self.is_oauth: rem = self.remaining_seconds if rem is None: @@ -84,10 +195,12 @@ def health_status(self) -> str: if rem < 900: return "expiring_soon" return "active" + if self.has_api_key: if self.data.get("rateLimitedUntil"): return "rate_limited" - return "active" - if self.data.get("baseUrl") or "ollama" in self.provider.lower(): - return "active" - return "active" if self.data.get("testStatus") in ("active", "ok", "success") else "unknown" + # Never probed yet: say so instead of claiming health nobody verified. + return "active" if probed == "valid" else "not_checked" + + # 9Router writes "ok", OmniRoute writes "active"; both mean healthy. + return "active" if self.data.get("testStatus") in ("ok", "active", "success") else "unknown" diff --git a/src/nine_rtksync/prefs.py b/src/nine_rtksync/prefs.py new file mode 100644 index 0000000..3aeb855 --- /dev/null +++ b/src/nine_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 9Router 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/nine_rtksync/providers/api_keys.py b/src/nine_rtksync/providers/api_keys.py index e93e5fb..a11948b 100644 --- a/src/nine_rtksync/providers/api_keys.py +++ b/src/nine_rtksync/providers/api_keys.py @@ -6,33 +6,56 @@ from datetime import datetime, timezone 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_api_key, +) from ..models import ConnectionRecord from .base import BaseProvider class ApiKeyProvider(BaseProvider): - """Health monitor for static API key providers.""" + """Health monitor for static API key providers. - HEALTH_CHECK_ENDPOINTS = { - "groq": "https://api.groq.com/openai/v1/models", - "mistral": "https://api.mistral.ai/v1/models", - "openrouter": "https://openrouter.ai/api/v1/models", - "gemini": "https://generativelanguage.googleapis.com/v1beta/models", - "openai": "https://api.openai.com/v1/models", - } + Validation is off by default in constructors that do not ask for it, so a + test or a dry run never reaches out to the internet by accident. + """ - def __init__(self, discovery: Optional[Any] = None): + def __init__( + self, + discovery: Optional[Any] = None, + validate_credentials: bool = False, + validation_timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Any] = None, + ): self.discovery = discovery + self.validate_credentials = validate_credentials + self.validation_timeout = validation_timeout + self.opener = opener def can_handle(self, conn: ConnectionRecord) -> bool: - return conn.has_api_key + # Uma instancia local carrega uma chave de fachada, entao `has_api_key` + # sozinho tambem casaria com ela. Como este provider vem antes do + # LocalProvider na lista, o laco daria break aqui e o catalogo local + # nunca seria descoberto -- e por isso que o Ollama local aparecia sem + # modelo nenhum no painel. + return conn.has_api_key and not conn.is_local def check_and_refresh( self, conn: ConnectionRecord, margin_seconds: int = 900, **kwargs ) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: messages: List[str] = [] data = dict(conn.data) + # `modified` decide se vale gravar no banco; `renewed` decide se conta + # como renovacao no resumo do ciclo. Sao coisas diferentes: 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 # 1. Check if newer API key was discovered on host if self.discovery: @@ -40,6 +63,7 @@ def check_and_refresh( if local and local.get("apiKey") and local.get("apiKey") != data.get("apiKey"): data["apiKey"] = local["apiKey"] modified = True + renewed = True src = local.get("source_path", "host") messages.append(f"API key synchronized from host local credential ({src})") @@ -50,15 +74,43 @@ def check_and_refresh( modified = True messages.append("Proactively removed rateLimitedUntil lock") - # 3. Update health status stamp if necessary - if not data.get("testStatus") or data.get("testStatus") != "active": - data["testStatus"] = "active" - data["lastTested"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + # 3. Ask the provider whether the key still works. + # + # This used to stamp testStatus = "ok" unconditionally, which is why the + # panel showed every API key as healthy: nothing had ever been verified, + # and a revoked key stayed green until a real request failed. + if self.validate_credentials: + result = check_api_key( + conn.provider, + # `data` ja pode conter a chave recem-descoberta no passo 1; + # usar `conn.api_key` mandaria a chave velha ao provedor e + # gravaria "invalida" justamente quando ela acabou de ser + # consertada. + str(data.get("apiKey") or conn.api_key or ""), + base_url=conn.base_url, + timeout=self.validation_timeout, + opener=self.opener, + ) + data.update(result.to_dict()) + data["lastTested"] = result.checked_at modified = True - messages.append("Connection status marked as operational (active)") + + if result.state == STATE_VALID: + data["testStatus"] = "active" + messages.append(f"API key accepted by the provider ({result.detail})") + elif result.state == STATE_INVALID: + # Do not claim health the provider just denied. + data["testStatus"] = "invalid" + messages.append(f"API key REJECTED by the provider ({result.detail})") + elif result.state == STATE_RATE_LIMITED: + messages.append(f"Provider rate limited the validation ({result.detail})") + elif result.state == STATE_UNREACHABLE: + messages.append(f"Provider unreachable, key not verified: {result.detail}") + else: + messages.append(result.detail or "Credential not verifiable") if not messages: - messages.append("API key active and healthy") + messages.append("API key unchanged") - return modified, data if modified else None, messages + return renewed, data if modified else None, messages diff --git a/src/nine_rtksync/providers/local.py b/src/nine_rtksync/providers/local.py index b148b84..690240c 100644 --- a/src/nine_rtksync/providers/local.py +++ b/src/nine_rtksync/providers/local.py @@ -1,24 +1,88 @@ """Handler for local and OpenAI-compatible providers (Ollama, vLLM, LMStudio).""" +import json +import urllib.error +import urllib.request from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from ..models import ConnectionRecord from .base import BaseProvider +# Catalog endpoints, in attempt order: Ollama-native and the OpenAI standard. +MODEL_CATALOG_PATHS = ("/api/tags", "/v1/models", "/models") +PROBE_TIMEOUT_SECONDS = 3.0 + class LocalProvider(BaseProvider): """Health monitor for local instances and OpenAI-compatible proxies.""" def can_handle(self, conn: ConnectionRecord) -> bool: - p = conn.provider.lower() - return ( - "ollama" in p - or "openai-compatible" in p - or "vllm" in p - or "lmstudio" in p - or bool(conn.data.get("baseUrl") and not conn.is_oauth and not conn.has_api_key) - ) + return conn.is_local + + def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], str]: + """Query the local instance catalog. + + Returns ``(models, error)``. An **empty error means the instance + answered**, even with an empty catalog: a freshly installed Ollama with + no model pulled is online, and reporting it as unreachable would light + up the panel for a service that is working. + """ + 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": "9RTKSync-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: ConnectionRecord, margin_seconds: int = 900, **kwargs @@ -34,15 +98,43 @@ def check_and_refresh( modified = True messages.append("Removed rateLimitedUntil lock from local connection") - # Ensure active status - if not data.get("testStatus") or data.get("testStatus") != "active": - data["testStatus"] = "active" - data["lastTested"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - modified = True - messages.append("Local status marked as operational (active)") + # Discover the models the local instance serves, so the panel can show them. + models, probe_error = self.discover_models(conn.base_url or "", conn.api_key or "") + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + # Erro vazio significa que a instancia respondeu -- com catalogo cheio ou + # vazio. Uma instalacao recem-feita, sem modelo baixado, esta no ar. + if models or not probe_error: + # Inclui o caso de esvaziar: uma instancia que tinha modelos e + # passou a nao ter precisa deixar de exibi-los, senao a tela mostra + # para sempre um catalogo que nao existe mais. + if data.get("discoveredModels") != models: + data["discoveredModels"] = models + modified = True + if models: + messages.append( + f"Local instance answered with {len(models)} model(s): {', '.join(models[:5])}" + ) + else: + messages.append("Local instance answered with an empty model catalog") - if not messages: - messages.append("Local service active and operational") + if data.get("testStatus") != "active": + data["testStatus"] = "active" + data["lastTested"] = now_iso + modified = True + messages.append("Local status marked as operational (active)") + else: + # With no catalog response the connection is not assumed healthy: this is + # exactly the "the local Ollama went down and nobody noticed" case. + messages.append(f"Local instance did not answer the model catalog: {probe_error}") + if data.get("testStatus") != "unreachable": + data["testStatus"] = "unreachable" + data["lastError"] = probe_error + data["lastTested"] = now_iso + modified = True - return modified, data if modified else None, messages + # Uma sondagem local nunca renova credencial: ela descobre catalogo e + # estado. O primeiro elemento e a contagem de renovacao do ciclo, entao + # aqui e sempre False; o dado segue para ser gravado assim mesmo. + return False, data if modified else None, messages diff --git a/src/nine_rtksync/web/index.html b/src/nine_rtksync/web/index.html deleted file mode 100644 index cb35898..0000000 --- a/src/nine_rtksync/web/index.html +++ /dev/null @@ -1,634 +0,0 @@ - - - - - - 9RTKSync · 9Router Universal Token & Connection Synchronizer - - - -
- - -
-
- ⚠️ Security Notice: You are using default credentials (admin / pathbit). It is strongly recommended to update your credentials to protect the dashboard. -
- -
- -
-
-

⚡ 9RTKSync

-

9Router Universal Token & Connection Synchronizer (9Router)

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

Validates HTTP response, latency, and access to the shared SQLite database.

-
-
Gateway URL:-
-
Gateway Status:Awaiting test...
-
HTTP Latency:-
-
SQLite Database:-
-
Diagnostics:Click 'Test Connection'
-
-
- - -
-
- ⏰ Cron Scheduler (Continuous Renewal) - -
-
-
- Active - (every 300s) -
- Total cycles: 0 -
-
-
Last Run:-
-
Next Run:-
-
Renewed Tokens:0
-
Last Result:-
-
-
- -
- - -
🔌 Monitored Connections (OAuth, API Key, and Local Providers)
-
- - - - - - - - - - - - - -
ProviderNameTypeStatusRemaining Validity
Loading database connections...
-
- - -
🔀 Resilience & Fallback Combos
-
- - - - - - - - - - - - -
Combo NameTypeModel CountCascade Models
Loading registered combos...
-
- - -
📋 Cron Cycle History
-
- - - - - - - - - - - - - - -
Date & Time (UTC)TriggerDurationAccounts InspectedOAuth RenewedStatus
No cycles executed yet.
-
- - -
- - - - - - - - diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py new file mode 100644 index 0000000..cb41c1a --- /dev/null +++ b/src/nine_rtksync/web/render.py @@ -0,0 +1,729 @@ +"""Renderização server-side do dashboard do 9RTKSync. + +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" + 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))}' + ) + + # 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) + + 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""" + + + + + + 9RTKSync + + + + + + + + + +
    + + {render_flash(flash)} + {render_security_banner(is_default_password, lang)} + +
    +
    + +
    +

    9RTKSync

    +

    + {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/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py index 460a534..a82256f 100644 --- a/src/nine_rtksync/web/server.py +++ b/src/nine_rtksync/web/server.py @@ -12,17 +12,37 @@ from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from typing import Any, Callable, Dict, Optional +from urllib.parse import parse_qs, urlparse + from ..config import Settings +from ..i18n import DEFAULT_LANGUAGE, normalize_language, translate +from ..prefs import get_preference, resolve_prefs_path, set_preference from ..database import get_all_combos, get_all_connections from ..models import ConnectionRecord +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. +ROUTER_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) + +# Cache do resultado da sondagem ao gateway, compartilhado entre as threads do servidor. +_router_probe_cache: Dict[str, tuple] = {} +_router_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) @@ -45,7 +65,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 @@ -56,7 +75,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 @@ -64,97 +85,436 @@ 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="9RTKSync 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_api_status() - elif self.path == "/api/cron-status": + elif route.path == "/api/cron-status": self.serve_cron_status() else: self.send_error(HTTPStatus.NOT_FOUND, "Page 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_request() - 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, "Endpoint not found") + def redirect_to_dashboard(self, tone: str, message: str) -> None: + """Redireciona para a pagina com uma mensagem de resultado.""" + from urllib.parse import urlencode + + 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 proxima renderizacao releia tudo. + + Sem isto, o resultado da sondagem ao gateway continuaria valendo por ate + 30s e o painel exibiria um estado anterior a acao que o usuario acabou + de disparar. + """ + with _router_probe_lock: + _router_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/atualizar": + # Recarga completa: zera os caches e volta para a pagina, que e + # 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 type(self).sync_trigger_callback: + self.redirect_to_dashboard("warning", "Sincronizacao manual indisponivel nesta instancia.") + return + try: + res = type(self).sync_trigger_callback() or {} + # A sincronizacao muda o estado do gateway: o cache anterior + # deixaria a tela mostrando o mundo de antes da acao. + self.invalidate_caches() + self.redirect_to_dashboard( + "success", + f"Sincronizacao concluida: {res.get('total_connections', 0)} conexoes inspecionadas, " + f"{res.get('normalized', 0)} normalizadas, {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 _router_probe_lock: + _router_probe_cache.pop(self.router_url, None) + online = self.probe_router() + self.redirect_to_dashboard( + "success" if online else "danger", + "Gateway respondeu normalmente." if online else "Gateway nao respondeu.", + ) + return + + 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/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 politica de forca e obrigatoria: 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 probe_router(self) -> bool: + """Sonda o gateway com cache: o resultado vale por ROUTER_PROBE_TTL_SECONDS.""" + if not self.router_url: + return True + + now = time.time() + with _router_probe_lock: + cached_at, cached_ok = _router_probe_cache.get(self.router_url, (0.0, None)) + if cached_ok is not None and (now - cached_at) < ROUTER_PROBE_TTL_SECONDS: + return cached_ok + + try: + req = urllib.request.Request( + self.router_url, + headers={"User-Agent": "9RTKSync-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 + + with _router_probe_lock: + _router_probe_cache[self.router_url] = (time.time(), router_ok) + return router_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 excecao no log. + + A sondagem ao gateway passa por probe_router, que memoriza o resultado; + sem isso cada probe pagava ate 3s de HTTP de saida 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.router_url: - now = time.time() - if now - DashboardHandler._last_gw_check < 15.0: - router_ok = DashboardHandler._last_gw_ok - else: - try: - req = urllib.request.Request( - self.router_url, - headers={"User-Agent": "9RTKSync-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 - DashboardHandler._last_gw_check = now - DashboardHandler._last_gw_ok = router_ok + router_ok = self.probe_router() 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") + 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 "ROUTER_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"ROUTER_SERVICE_UNREACHABLE" - def serve_html(self): - html_path = os.path.join(os.path.dirname(__file__), "index.html") - if os.path.exists(html_path): - with open(html_path, "rb") as f: - content = f.read() - else: - content = b"

    9RTKSync Dashboard

    index.html not found

    " + 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 prefs_path(self) -> str: + """Banco de preferencias proprio do sincronizador (nunca o do gateway).""" + base = os.path.dirname(self.settings.get_auth_file_path()) if self.settings else "" + return resolve_prefs_path(base or os.path.expanduser("~")) + + def resolve_language(self) -> str: + """Idioma em vigor: preferencia salva no SQLite, senao o padrao (ingles).""" + return normalize_language(get_preference(self.prefs_path(), "language", DEFAULT_LANGUAGE)) + + def collect_dashboard_state(self) -> Dict[str, Any]: + """Le tudo o que a pagina precisa. Roda no servidor: o SQLite nunca sai daqui.""" + conns: list = [] + combos: list = [] + db_exists = bool(self.db_path and os.path.exists(self.db_path)) + if db_exists: + try: + conns = get_all_connections(self.db_path) + except Exception: + conns = [] + try: + combos = get_all_combos(self.db_path) + except Exception: + combos = [] + + start_t = time.time() + online = self.probe_router() + latency_ms = int((time.time() - start_t) * 1000) + + return { + "connections": conns, + "combos": combos, + "cron": self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False}, + "gateway": { + "url": self.router_url, + "online": online, + "statusCode": 200 if online else 0, + "latencyMs": latency_ms, + # Bandeira explicita: o resumo e texto para humano e vinha + # sempre preenchido, inclusive com "Banco nao encontrado". + # Converter esse texto em booleano fazia a tela declarar banco e + # gateway 100% operacionais justamente quando o arquivo sumia. + "dbOk": db_exists, + "dbSummary": ( + f"Operacional ({len(conns)} conexoes, {len(combos)} combos)" + if db_exists + else "Banco nao encontrado" + ), + }, + } + + 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 = "admin" + is_default = False + auth_from_env = False + refresh_margin = 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.router_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 serve_api_status(self): conns = [] @@ -171,7 +531,7 @@ def serve_api_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", @@ -199,10 +559,9 @@ def serve_api_status(self): body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Access-Control-Allow-Origin", "*") 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} @@ -210,7 +569,7 @@ def serve_cron_status(self): self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_test_gateway(self): start_t = time.time() @@ -266,7 +625,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: @@ -275,13 +634,17 @@ 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 contain at least 4 characters."}).encode("utf-8") + # Mesma politica de forca do formulario 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: @@ -296,7 +659,7 @@ def handle_change_password(self, raw_body: bytes): 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, "Could not save credentials") @@ -306,7 +669,7 @@ def handle_change_password(self, raw_body: bytes): 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_request(self): if DashboardHandler.sync_trigger_callback: @@ -317,7 +680,7 @@ def handle_sync_request(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") @@ -325,7 +688,7 @@ def handle_sync_request(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.send_error(HTTPStatus.SERVICE_UNAVAILABLE, "Synchronizer unavailable") @@ -338,7 +701,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") @@ -346,7 +709,7 @@ 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_request() diff --git a/tests/test_auth_recovery.py b/tests/test_auth_recovery.py new file mode 100644 index 0000000..3a8cd64 --- /dev/null +++ b/tests/test_auth_recovery.py @@ -0,0 +1,273 @@ +"""Testes da regra de autenticação do dashboard, incluindo a credencial de recuperação.""" + +import json +import os +import stat +import tempfile +import logging +import unittest +import unittest.mock + +from nine_rtksync import cli as nine_rtksync_cli + +from nine_rtksync.auth import ( + constant_time_equals, + derive_recovery_hash, + ensure_recovery_hash, + read_stored_credentials, + resolve_recovery_hash, + verify_credentials, +) +from nine_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 ColetorDeLog(logging.Handler): + """Guarda a mensagem ja formatada de cada registro emitido.""" + + def __init__(self): + super().__init__() + self.mensagens = [] + + def emit(self, record): + self.mensagens.append(record.getMessage()) + + +class TestRecoveryHashIsNeverLogged(unittest.TestCase): + """O stdout do container e coletado e encaminhado: credencial funcional nao vai para la. + + A verificacao e de comportamento, nao de texto do codigo-fonte: o que importa + e o que sai no log quando a credencial e gerada de verdade. + """ + + def test_the_startup_log_names_the_file_and_never_the_value(self): + with tempfile.TemporaryDirectory() as tmp: + arquivo = os.path.join(tmp, ".dashboard_recovery") + valor, gerado = ensure_recovery_hash(arquivo) + self.assertTrue(gerado) + self.assertTrue(valor) + + coletor = ColetorDeLog() + logger = logging.getLogger("teste.recuperacao") + logger.addHandler(coletor) + logger.setLevel(logging.INFO) + self.addCleanup(logger.removeHandler, coletor) + + # Reproduz exatamente a chamada que o CLI faz ao gerar a credencial. + logger.warning( + "[AUTH] Recovery credential generated for user 'admin'. Read it with: " + "docker exec cat %s (or pin your own with DASHBOARD_RECOVERY_HASH)", + arquivo, + ) + + saida = "\n".join(coletor.mensagens) + self.assertNotIn(valor, saida, "o valor da credencial nunca pode aparecer no log") + self.assertIn(arquivo, saida, "o log precisa dizer onde ler o valor") + + def test_the_cli_passes_the_path_and_not_the_hash(self): + """Guarda contra alguem trocar o argumento do log pelo proprio valor.""" + chamadas = [] + + class LoggerFalso: + def warning(self, *args, **kwargs): + chamadas.append(args) + + def info(self, *args, **kwargs): + pass + + def error(self, *args, **kwargs): + pass + + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "data.sqlite") + open(db, "wb").close() + argv = ["9rtksync", "--status", "--db-path", db] + with unittest.mock.patch("sys.argv", argv), \ + unittest.mock.patch.object(nine_rtksync_cli, "setup_logging", return_value=LoggerFalso()), \ + unittest.mock.patch.object(nine_rtksync_cli, "print_status_table", lambda settings: None): + nine_rtksync_cli.main() + + for args in chamadas: + for argumento in args[1:]: + # 64 hex e a forma do hash de recuperacao. + texto = str(argumento) + self.assertFalse( + len(texto) == 64 and all(c in "0123456789abcdef" for c in texto), + f"o CLI passou um valor com forma de credencial para o log: {texto[:8]}...", + ) diff --git a/tests/test_credential_check.py b/tests/test_credential_check.py new file mode 100644 index 0000000..004dd80 --- /dev/null +++ b/tests/test_credential_check.py @@ -0,0 +1,266 @@ +"""Testes da validacao viva de credenciais. + +Nenhum teste aqui toca a rede: todo probe recebe um opener falso. Foi um teste +que dependia de um servico real que derrubou a CI antes. +""" + +import json +import unittest +import urllib.error + +from nine_rtksync import credential_check as cc +from nine_rtksync.models import ConnectionRecord +from nine_rtksync.providers.api_keys 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): + return ConnectionRecord( + id="c1", + provider=provider, + name=provider, + created_at="", + updated_at="", + data_raw=json.dumps(payload), + ) + + 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): + return ConnectionRecord( + id="c1", + provider=provider, + name=provider, + created_at="", + updated_at="", + data_raw=json.dumps(payload), + ) + + 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 test_rejected_key_is_never_stamped_as_ok(self): + """O bug original: testStatus = 'ok' era carimbado sem perguntar a ninguem.""" + provider = ApiKeyProvider(validate_credentials=True, opener=opener_returning(401)) + conn = self.build("groq", {"apiKey": "revogada", "testStatus": "ok"}) + _, data, msgs = provider.check_and_refresh(conn) + self.assertEqual(data["testStatus"], "invalid") + self.assertEqual(data["credentialState"], cc.STATE_INVALID) + self.assertTrue(any("REJECTED" 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)) + conn = self.build("groq", {"apiKey": "boa"}) + _, data, _ = provider.check_and_refresh(conn) + 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")) + ) + conn = self.build("groq", {"apiKey": "boa", "testStatus": "ok"}) + _, data, _ = provider.check_and_refresh(conn) + self.assertNotEqual(data.get("testStatus"), "invalid") + self.assertEqual(data["credentialState"], cc.STATE_UNREACHABLE) + + +class TestHealthStatusUsesTheProbe(unittest.TestCase): + def build(self, provider, payload): + return ConnectionRecord( + id="c1", + provider=provider, + name=provider, + created_at="", + updated_at="", + data_raw=json.dumps(payload), + ) + + 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_discovery.py b/tests/test_discovery.py index 2b08d89..f658957 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -5,13 +5,13 @@ import shutil import tempfile import unittest +import unittest.mock from nine_rtksync.discovery import HostDiscoveryEngine from nine_rtksync.models import ConnectionRecord from nine_rtksync.providers.api_keys import ApiKeyProvider from nine_rtksync.providers.google import GoogleProvider from nine_rtksync.providers.local import LocalProvider -from nine_rtksync.providers.oauth import GenericOAuthProvider class TestDiscoveryEngine(unittest.TestCase): @@ -112,7 +112,9 @@ def test_providers_with_discovery(self): self.assertTrue(mod) self.assertEqual(data["apiKey"], "sk-ant-new-host") - # 3. Local Provider handles Ollama + # 3. Local Provider handles Ollama. + # The catalog probe is stubbed so the test never depends on something + # actually listening on 11434 — it passed locally and failed in CI before. lp = LocalProvider() conn_ollama = ConnectionRecord( id="c3", @@ -123,9 +125,27 @@ def test_providers_with_discovery(self): data_raw=json.dumps({"baseUrl": "http://127.0.0.1:11434/v1"}), ) self.assertTrue(lp.can_handle(conn_ollama)) - mod, data, msgs = lp.check_and_refresh(conn_ollama) - self.assertTrue(mod) + + # O primeiro elemento conta renovacao de credencial; uma sondagem local + # nunca renova nada, entao e sempre False. O que prova que funcionou e o + # dicionario devolvido para gravacao. + with unittest.mock.patch.object( + LocalProvider, "discover_models", return_value=(["llama3.2:3b", "qwen2.5:7b"], "") + ): + renewed, data, msgs = lp.check_and_refresh(conn_ollama) + self.assertFalse(renewed, "sondagem local nao pode contar como renovacao no ciclo") + self.assertIsNotNone(data) self.assertEqual(data["testStatus"], "active") + self.assertEqual(data["discoveredModels"], ["llama3.2:3b", "qwen2.5:7b"]) + + # An instance that stops answering must not be reported as healthy. + with unittest.mock.patch.object( + LocalProvider, "discover_models", return_value=([], "Connection refused") + ): + renewed, data, msgs = lp.check_and_refresh(conn_ollama) + self.assertFalse(renewed) + self.assertIsNotNone(data) + self.assertEqual(data["testStatus"], "unreachable") if __name__ == "__main__": diff --git a/tests/test_env_documentation.py b/tests/test_env_documentation.py new file mode 100644 index 0000000..51f712a --- /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 nine_rtksync + +RAIZ_PACOTE = pathlib.Path(nine_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 d343225..53eb654 100644 --- a/tests/test_gateway_diag.py +++ b/tests/test_gateway_diag.py @@ -10,7 +10,7 @@ from unittest.mock import patch, MagicMock from nine_rtksync.config import Settings -from nine_rtksync.web.server import DashboardHandler, start_web_server +from nine_rtksync.web.server import start_web_server class TestGatewayDiag(unittest.TestCase): diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..091a4f5 --- /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 nine_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_password_policy.py b/tests/test_password_policy.py new file mode 100644 index 0000000..fc3513b --- /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 nine_rtksync.auth import ( + hash_password, + password_matches, + read_db_credentials, + validate_password_strength, + write_db_credentials, +) +from nine_rtksync.config import Settings +from nine_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..cf4fbc2 --- /dev/null +++ b/tests/test_provider_dispatch.py @@ -0,0 +1,233 @@ +"""Regressões do despacho de providers e da contagem de renovações. + +Cada teste aqui nasceu de um apontamento da revisão automática do PR. O que eles +protegem, em uma frase cada: + +- 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; +- carimbar o horário de uma verificação virava "credencial renovada" no resumo + do ciclo; +- a chave recém-descoberta no host não era a que ia para o provedor; +- um catálogo vazio (instalação nova, sem modelo) 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 do + nome, e a chave saía para o endereço público do fornecedor. +""" + +import json +import unittest +import unittest.mock + +from nine_rtksync.credential_check import ( + STATE_UNREACHABLE, + STATE_VALID, + CheckResult, + _classify, + check_api_key, +) +from nine_rtksync.models import ConnectionRecord +from nine_rtksync.providers import ApiKeyProvider, LocalProvider + + +def conexao(provider: str, **dados) -> ConnectionRecord: + return ConnectionRecord( + id="c1", + provider=provider, + name="Teste", + created_at="2026-01-01", + updated_at="2026-01-01", + data_raw=json.dumps(dados), + ) + + +class TestLocalNaoEEngolidaPeloHandlerDeChave(unittest.TestCase): + """O sintoma era o Ollama local aparecendo sem modelo nenhum no painel.""" + + def setUp(self): + self.local = conexao( + "openai-compatible-chat-ollama-local", + baseUrl="http://127.0.0.1:11434/v1", + apiKey="chave-de-fachada", + ) + + def test_the_api_key_handler_declines_a_local_connection(self): + self.assertFalse(ApiKeyProvider().can_handle(self.local)) + + def test_the_local_handler_takes_it(self): + self.assertTrue(LocalProvider().can_handle(self.local)) + + def test_exactly_one_handler_claims_it_and_it_is_the_local_one(self): + # Reproduz o laço do motor, inclusive a ordem em que ele monta a lista. + ordem = [ApiKeyProvider(), LocalProvider()] + escolhidos = [p for p in ordem if p.can_handle(self.local)] + self.assertEqual(len(escolhidos), 1) + self.assertIsInstance(escolhidos[0], LocalProvider) + + def test_a_cloud_key_still_goes_to_the_api_key_handler(self): + nuvem = conexao("groq", apiKey="gsk_qualquer") + self.assertTrue(ApiKeyProvider().can_handle(nuvem)) + self.assertFalse(LocalProvider().can_handle(nuvem)) + + +class TestVerificacaoNaoERenovacao(unittest.TestCase): + """"N renovadas" precisa significar N credenciais trocadas.""" + + def test_a_plain_validation_does_not_count_as_a_renewal(self): + provider = ApiKeyProvider(validate_credentials=True) + with unittest.mock.patch( + "nine_rtksync.providers.api_keys.check_api_key", + return_value=CheckResult(state=STATE_VALID, detail="ok", checked_at="2026-01-01T00:00:00Z"), + ): + renewed, data, _ = provider.check_and_refresh(conexao("groq", apiKey="gsk_a")) + self.assertFalse(renewed, "verificar chave nao e renovar chave") + # Mas o resultado da verificacao tem de ser gravado assim mesmo. + self.assertIsNotNone(data) + self.assertEqual(data["testStatus"], "active") + + def test_a_key_replaced_from_the_host_does_count(self): + class DescobertaFalsa: + def get_credential_for_provider(self, provider): + return {"apiKey": "gsk_nova", "source_path": "~/.config/x"} + + provider = ApiKeyProvider(discovery=DescobertaFalsa(), validate_credentials=False) + renewed, data, _ = provider.check_and_refresh(conexao("groq", apiKey="gsk_antiga")) + self.assertTrue(renewed) + self.assertEqual(data["apiKey"], "gsk_nova") + + +class TestAChaveSondadaEAMaisNova(unittest.TestCase): + def test_the_probe_uses_the_key_just_discovered(self): + class DescobertaFalsa: + def get_credential_for_provider(self, provider): + return {"apiKey": "gsk_nova", "source_path": "host"} + + vistas = [] + + def espiao(provider, api_key, **kwargs): + vistas.append(api_key) + return CheckResult(state=STATE_VALID, detail="ok", checked_at="2026-01-01T00:00:00Z") + + provider = ApiKeyProvider(discovery=DescobertaFalsa(), validate_credentials=True) + with unittest.mock.patch("nine_rtksync.providers.api_keys.check_api_key", side_effect=espiao): + provider.check_and_refresh(conexao("groq", apiKey="gsk_revogada")) + + self.assertEqual(vistas, ["gsk_nova"], + "sondar com a chave velha marcaria como invalida a chave que acabou de ser consertada") + + +class TestCatalogoVazioNaoEQueda(unittest.TestCase): + def test_an_instance_answering_with_no_model_is_still_up(self): + provider = LocalProvider() + conn = conexao("openai-compatible-local", baseUrl="http://127.0.0.1:11434/v1") + # Erro vazio = respondeu; lista vazia = nenhum modelo baixado ainda. + with unittest.mock.patch.object(LocalProvider, "discover_models", return_value=([], "")): + _, data, msgs = provider.check_and_refresh(conn) + 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): + provider = LocalProvider() + conn = conexao("openai-compatible-local", baseUrl="http://127.0.0.1:11434/v1") + with unittest.mock.patch.object(LocalProvider, "discover_models", return_value=([], "Connection refused")): + _, data, _ = provider.check_and_refresh(conn) + 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): + """Chave de cliente não pode sair para o endereço público do fornecedor.""" + + def urls_sondadas(self, provider, api_key, 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("nine_rtksync.credential_check._execute", side_effect=espiao): + check_api_key(provider, api_key, base_url=base_url) + return vistas + + def test_an_azure_openai_key_never_reaches_api_openai_com(self): + urls = self.urls_sondadas("azure-openai", "k", "https://minha-org.openai.azure.com/v1") + self.assertEqual(len(urls), 1) + self.assertNotIn("api.openai.com", urls[0]) + self.assertIn("minha-org.openai.azure.com", urls[0]) + + def test_a_proxied_anthropic_key_never_reaches_api_anthropic_com(self): + urls = self.urls_sondadas("anthropic", "k", "https://proxy.interno.example/v1") + self.assertNotIn("api.anthropic.com", urls[0]) + self.assertIn("proxy.interno.example", urls[0]) + + def test_the_vendor_endpoint_is_still_used_when_no_address_is_declared(self): + urls = self.urls_sondadas("anthropic", "k", None) + self.assertIn("api.anthropic.com", urls[0]) + + def test_a_base_url_on_the_vendor_host_keeps_the_specific_probe(self): + # Mesmo host: continua valendo a sonda especifica do fornecedor. + urls = self.urls_sondadas("openrouter", "k", "https://openrouter.ai/api/v1") + self.assertIn("openrouter.ai", urls[0]) + + +if __name__ == "__main__": + unittest.main() + + +class TestAutenticacaoDoFornecedorSobrevive(unittest.TestCase): + """Trocar o endereço não pode trocar o jeito de autenticar. + + A Anthropic espera `x-api-key` e o Gemini `x-goog-api-key`. Quando a conexão + declara um proxy próprio, só o **endereço** muda; substituir a sonda inteira + trocava o cabeçalho por um Bearer genérico, e o proxy recusava uma chave + perfeitamente válida. + """ + + def cabecalhos_da_sonda(self, provider, base_url): + vistos = {} + + def espiao(request, timeout, opener=None, spec_invalid=()): + vistos.update({k.lower(): v for k, v in request.header_items()}) + vistos["__url__"] = request.full_url + return CheckResult(state=STATE_VALID, detail="ok", checked_at="2026-01-01T00:00:00Z") + + with unittest.mock.patch("nine_rtksync.credential_check._execute", side_effect=espiao): + check_api_key(provider, "a-chave", base_url=base_url) + return vistos + + def test_a_proxied_anthropic_keeps_its_x_api_key_header(self): + vistos = self.cabecalhos_da_sonda("anthropic", "https://proxy.interno.example/v1") + self.assertIn("proxy.interno.example", vistos["__url__"]) + self.assertEqual(vistos.get("x-api-key"), "a-chave") + self.assertNotIn("authorization", vistos) + + def test_a_proxied_gemini_keeps_its_google_header_and_its_400_rule(self): + vistos = self.cabecalhos_da_sonda("gemini", "https://proxy.interno.example/v1") + self.assertEqual(vistos.get("x-goog-api-key"), "a-chave") + + def test_a_provider_that_uses_bearer_still_uses_bearer(self): + vistos = self.cabecalhos_da_sonda("groq", "https://proxy.interno.example/v1") + self.assertEqual(vistos.get("authorization"), "Bearer a-chave") + + +class TestCatalogoQueEsvaziou(unittest.TestCase): + def test_an_instance_that_lost_every_model_stops_showing_them(self): + conn = conexao( + "openai-compatible-local", + baseUrl="http://127.0.0.1:11434/v1", + discoveredModels=["llama3.2:3b"], + testStatus="active", + ) + with unittest.mock.patch.object(LocalProvider, "discover_models", return_value=([], "")): + _, data, _ = LocalProvider().check_and_refresh(conn) + self.assertIsNotNone(data, "esvaziar o catalogo e uma mudanca que precisa ser gravada") + self.assertEqual(data["discoveredModels"], []) diff --git a/tests/test_startup_storage.py b/tests/test_startup_storage.py new file mode 100644 index 0000000..c376173 --- /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 nine_rtksync.auth import read_db_credentials, write_db_credentials +from nine_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 2c3b325..48f9f71 100644 --- a/tests/test_web_auth.py +++ b/tests/test_web_auth.py @@ -7,7 +7,6 @@ import tempfile import urllib.error import urllib.request -from http import HTTPStatus import unittest from nine_rtksync.config import Settings diff --git a/tests/test_web_render.py b/tests/test_web_render.py new file mode 100644 index 0000000..3270e8e --- /dev/null +++ b/tests/test_web_render.py @@ -0,0 +1,357 @@ +"""Testes da renderização server-side do dashboard.""" + +import base64 +import json +import os +import re +import sqlite3 +import tempfile +import time +import unittest +import urllib.error +import urllib.request + +from nine_rtksync import i18n +from nine_rtksync.config import Settings +from nine_rtksync.models import ConnectionRecord +from nine_rtksync.web import render, server 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, + created_at="2026-09-12T00:00:00Z", + updated_at="2026-09-12T00:00:00Z", + data_raw="", + 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_every_health_status_has_a_badge_and_a_translation(self): + """Trava o acoplamento: o modelo devolve codigos que o render precisa conhecer.""" + from nine_rtksync import i18n + + produced = {"active", "expiring_soon", "expired", "rate_limited", "no_expiration", "unknown"} + self.assertEqual(produced - set(render.HEALTH_PRESENTATION), set()) + for status in produced: + for lang in i18n.LANGUAGES: + label = i18n.translate(f"health.{status}", lang) + self.assertNotEqual(label, f"health.{status}", f"sem traducao: {status}/{lang}") + self.assertNotIn("Unknown", render.health_badge(status, "en")) if status != "unknown" else None + + def test_health_badge_reflects_the_model_status(self): + now_ms = int(time.time() * 1000) + expired = make_conn("antigravity", "AG", { + "accessToken": "t", "refreshToken": "r", "expiresAt": now_ms - 1000, + }) + self.assertEqual(expired.health_status, "expired") + self.assertIn("Expired", render.health_badge(expired.health_status, "en")) + self.assertIn("text-bg-danger", render.health_badge(expired.health_status, "en")) + + 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://9router:20128", "online": True, "statusCode": 200, + "latencyMs": 9, "dbSummary": "Operacional (7 conexoes, 5 combos)"}, + db_path="/app/data/db/data.sqlite", + router_url="http://9router: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) + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, provider TEXT, name TEXT, " + "data TEXT, createdAt TEXT, updatedAt TEXT)" + ) + conn.execute("CREATE TABLE combos (id TEXT PRIMARY KEY, name TEXT, models TEXT, kind TEXT)") + conn.execute( + "INSERT INTO providerConnections VALUES (?,?,?,?,?,?)", + ("c1", "antigravity", "Google Antigravity Pro", + json.dumps({"accessToken": "tok", "refreshToken": "ref", + "expiresAt": now_ms + 24 * 60 * 1000}), + "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=19293, + dashboard_user="admin", dashboard_password="senha-forte", + dashboard_auth_from_env=True, + ) + cls.server = web_server.start_web_server( + "127.0.0.1", 19293, cls.db_path, router_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:19293{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("