diff --git a/.env.example b/.env.example index 1c84a87..8156787 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ DB_PATH=/app/data/storage.sqlite # 2. Conectividade com o Gateway OmniRoute # ------------------------------------------------------------------------------ # URL base do gateway OmniRoute para testes de conexao e diagnosticos -OMNIROUTE_URL=http://127.0.0.1:20128 +OMNIROUTE_URL=http://ominirtk-router:20128 # ------------------------------------------------------------------------------ # 3. Parametros de Sincronizacao e Agendador Cron @@ -85,7 +85,7 @@ DASHBOARD_PASSWORD= # Deixou DASHBOARD_PASSWORD vazia? O container gera uma credencial de # recuperacao no primeiro boot. Leia e entre com ela como usuario 'admin': -# docker exec ominirtksync cat /app/data/.dashboard_recovery +# docker exec ominirtk-sync cat /app/data/.dashboard_recovery # Depois defina a sua senha pela tela. # # Com DASHBOARD_PASSWORD preenchida, o ambiente vira a fonte da verdade e a @@ -99,6 +99,26 @@ DASHBOARD_PASSWORD= # quando e necessaria. # DASHBOARD_RECOVERY_HASH= +# ------------------------------------------------------------------------------ +# Entrada federada (SSO / OpenID Connect) -- opcional +# ------------------------------------------------------------------------------ +# O SSO e configurado PELA TELA (botao Configuracoes no cabecalho do painel), e +# nao por variaveis: issuer, client_id, endereco publico e lista de autorizados +# ficam no banco de preferencias. Estas duas variaveis existem porque nao +# pertencem ao banco. +# +# Segredo do cliente OAuth. Quando preenchido aqui, o ambiente VENCE o arquivo +# .sso_client_secret (modo 0600) e a tela trava o campo, do mesmo jeito que faz +# com DASHBOARD_PASSWORD. E um segredo: o lugar dele e o seu .env, que nunca e +# versionado, jamais este arquivo de exemplo. +# OIDC_CLIENT_SECRET= + +# Interruptor de emergencia. Com 1, a entrada federada e desligada sem tocar no +# banco e nenhuma rota /sso/* responde -- o formulario local volta a ser a unica +# porta. E o que salva quando o provedor de identidade cai e o painel esta atras +# de um tunel: suba com a variavel e entre com usuario e senha. +SSO_DISABLED=0 + # ------------------------------------------------------------------------------ # Log persistente em arquivo # ------------------------------------------------------------------------------ @@ -127,3 +147,20 @@ REQUIRE_LOGIN=false # que uma conexao esta saudavel so por carregar uma credencial. CREDENTIAL_CHECK_ENABLED=1 CREDENTIAL_CHECK_TIMEOUT=8 + +# --- Acesso remoto (opcional) ------------------------------------------------ +# Só têm efeito quando você sobe o perfil correspondente: +# docker compose --profile tunel up -d +# docker compose --profile tailnet up -d +# Leia docs/wiki/Remote-Access.md ANTES de ligar qualquer um dos dois: com a +# porta em 127.0.0.1 o painel só é alcançado por esta máquina, e um túnel +# inverte isso. + +# Vazio = quick tunnel da Cloudflare: URL nova a cada subida, pública para quem +# a tiver. Preenchido com o token de um túnel nomeado = URL estável e a +# possibilidade de pôr o Cloudflare Access na frente. +TUNNEL_TOKEN= + +# Chave efêmera gerada em https://login.tailscale.com/admin/settings/keys +# (efêmera para o nó sumir sozinho quando o contêiner morrer). +TS_AUTHKEY= diff --git a/.github/workflows/cleanup-packages.yml b/.github/workflows/cleanup-packages.yml index ce46b40..32ccfe9 100644 --- a/.github/workflows/cleanup-packages.yml +++ b/.github/workflows/cleanup-packages.yml @@ -5,7 +5,7 @@ name: Package Retention # Historico: a primeira versao deste arquivo nao era limpeza. Com # min-versions-to-keep: 0 e delete-only-untagged-versions: false ela apagava # TODAS as versoes e em seguida removia o proprio package via API. Quem -# estivesse puxando ghcr.io/pathbit/ominirtksyncatest ficava sem imagem. +# estivesse puxando ghcr.io/pathbit/ominirtksync 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 diff --git a/.gitignore b/.gitignore index 4b900f1..1e40a45 100644 --- a/.gitignore +++ b/.gitignore @@ -1231,6 +1231,9 @@ temp/ # Credenciais do painel: o hash de recuperacao e a senha em texto herdada. .dashboard_recovery .dashboard_auth.json +# Segredo do cliente OAuth da entrada federada. Mora no diretorio de dados +# (modo 0600), mas em execucao local esse diretorio pode ser o proprio repo. +.sso_client_secret # Log persistente: pode conter endereco, nome de conexao e mensagem de erro. *.log logs/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 8071d2f..8fdcc8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,13 @@ { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnPaste": true, - "explorer.autoReveal": true, - "explorer.compactFolders": false, - "files.exclude": { - "**/.git": false - }, - "claudeCode.includeCoAuthoredBy": false, - "git.includeCoAuthoredBy": false + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnPaste": true, + "explorer.autoReveal": true, + "explorer.compactFolders": false, + "files.exclude": { + "**/.git": false + }, + "claudeCode.includeCoAuthoredBy": false, + "git.includeCoAuthoredBy": false, + "git.ignoredRepositories": ["**/tmp/**"] } diff --git a/Dockerfile b/Dockerfile index ebe925d..3c6971c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,11 @@ COPY pyproject.toml /app/ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -e . +# Criado na imagem, com o dono que o compose usa: um volume nomeado herda o +# dono do diretorio que cobre. Sem isto ele nasce root e o processo (uid 1000) +# nao consegue escrever o proprio log. +RUN mkdir -p /app/logs && chown -R 1000:1000 /app/logs + EXPOSE 9090 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ diff --git a/Makefile b/Makefile index 26d3c08..c375f05 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,17 @@ # Cria o .env a partir do .env.example. Nunca sobrescreve um .env existente: # ele carrega os seus segredos, e um `make setup` distraido nao pode apaga-los. # O docker compose le esse .env sozinho, por estar ao lado do compose. -setup: +# A rede de inferencia e compartilhada pelos tres gateways e declarada como +# externa nos tres composes -- externa justamente para que nenhuma das stacks +# seja dona dela: qualquer uma pode subir primeiro, e derrubar uma nao leva a +# rede junto. O preco e que ela precisa existir antes do primeiro `up`, e o +# compose so diz "network declared as external, but could not be found". +rede-de-inferencia: + @docker network inspect rtk-inference-net >/dev/null 2>&1 \ + || docker network create rtk-inference-net >/dev/null \ + && echo "rede rtk-inference-net pronta." + +setup: rede-de-inferencia @if [ -f .env ]; then \ echo ".env ja existe — preservado."; \ else \ @@ -38,8 +48,13 @@ status: docker-build: docker build -t ominirtksync:latest -t ghcr.io/pathbit/ominirtksync:latest . +# O bind em 127.0.0.1 nao e detalhe: este painel le o banco do gateway e mostra +# a saude das credenciais. Sem o prefixo, "-p PORTA:9090" publica em TODA +# interface -- o Wi-Fi do cafe, a VLAN do escritorio -- enquanto os composes +# deste repo publicam so no loopback. Comentario FORA da receita: linha iniciada +# por # dentro de um alvo vai para o shell e aparece na saida. docker-run: - docker run --rm -it --name ominirtksync -p 9092:9090 ominirtksync:latest + docker run --rm -it --name ominirtk-sync -p 127.0.0.1:9092:9090 ominirtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/README.md b/README.md index a3af74e..392cede 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ [![Python Version](https://img.shields.io/badge/python-3.14.7-blue.svg)](https://www.python.org/ftp/python/3.14.7/python-3.14.7-macos11.pkg) [![Docker Package](https://img.shields.io/badge/docker-ghcr.io%2Fpathbit%2Fominirtksync-blue)](https://github.com/pathbit/OminiRTkSync/pkgs/container/ominirtksync) -O **`OminiRTKSync`** (*OminiRoute Universal Token & Connection Synchronizer*) é o sincronizador e guardião de conexões dedicado ao gateway [OmniRoute](https://github.com/diegosouzapw/OmniRoute). Ele gerencia a persistência relacional de credenciais, auto-renovação de tokens OAuth e prevenção de interrupções de rota em inteligência artificial. +**`OminiRTKSync`** (*OminiRoute Universal Token & Connection Synchronizer*) is the dedicated connection synchronizer and guardian for the [OmniRoute](https://github.com/diegosouzapw/OmniRoute) gateway. It manages relational credential persistence, proactive OAuth token renewal, and prevents routing outages in artificial intelligence workloads. -Caso esteja utilizando o 9Router original, utilize o projeto irmão [9RTKSync](https://github.com/pathbit/9RTKSync) configurado para a arquitetura do [9Router](https://github.com/decolua/9router). +If you are running the original 9Router, use the sibling project [9RTKSync](https://github.com/pathbit/9RTKSync), configured for the [9Router](https://github.com/decolua/9router) architecture. ## Documentation @@ -22,126 +22,130 @@ request; a push to `master` republishes the wiki automatically. --- -## Recursos Principais +## Core Features -* **Compatibilidade com Schema Relacional do OmniRoute** - * Sincronização direta com a tabela `provider_connections` do SQLite (`storage.sqlite`), manipulando campos nativos como `access_token`, `refresh_token`, `expires_at` e `test_status`. -* **Renovação Contínua de Tokens OAuth** - * Auto-renovação de contas Google Antigravity e Gemini CLI antes de sua expiração com margem de segurança ajustável. -* **Auto-Detecção de Bancos de Dados** - * Detecção automática entre caminhos padrão do container (`/app/data/storage.sqlite`) e instalações locais (`~/.omniroute/data/storage.sqlite`). -* **Dashboard Web Embutido** - * Painel de controle na porta `9090` (publicada em `9092`) para monitoramento do estado de cada conexão registrada e acionamento sob demanda de sincronização. -* **Isolamento Completo em Virtual Environment** - * Execução segura e isolada em ambiente virtual Python tanto em containers Docker (`/opt/venv`) quanto em instalações de desenvolvimento local (`.venv`). +* **OmniRoute Relational Schema Compatibility** + * Direct synchronization with the SQLite `provider_connections` table (`storage.sqlite`), handling native fields such as `access_token`, `refresh_token`, `expires_at`, and `test_status`. +* **Continuous OAuth Token Renewal** + * Proactive renewal of Google Antigravity and Gemini CLI accounts before expiration, with an adjustable safety margin. +* **Database Auto-Detection** + * Automatic detection between the standard container path (`/app/data/storage.sqlite`) and local installations (`~/.omniroute/data/storage.sqlite`). +* **Built-in Web Dashboard** + * Control panel on port `9090` (published on `9092`) for monitoring the state of every registered connection and triggering synchronization on demand. +* **Strict Virtual Environment Execution** + * Safe, isolated execution in a Python virtual environment both in Docker containers (`/opt/venv`) and in local development setups (`.venv`). --- --- -## 🔑 Como entrar no painel +## 🔑 Signing in to the dashboard | | | | :--- | :--- | -| **Endereço** | `http://localhost:9092` | -| **Usuário** | `admin` — ou o que você definir em `DASHBOARD_USER` | -| **Senha** | o valor de `DASHBOARD_PASSWORD` no seu `.env` | +| **Address** | `http://localhost:9092` | +| **User** | `admin` — or whatever you set in `DASHBOARD_USER` | +| **Password** | the value of `DASHBOARD_PASSWORD` in your `.env` | -**Não existe senha de fábrica**, e isso é deliberado: uma senha fixa publicada -na imagem vira credencial pública no instante em que a imagem é publicada. Você -escolhe a sua uma vez, num lugar só: +There is **no factory password**, and that is deliberate: a fixed password shipped +in an image is public the moment the image is. You choose it once, in one place: ```bash cp .env.example .env -# edite o .env: +# edit .env: DASHBOARD_USER=admin -DASHBOARD_PASSWORD= +DASHBOARD_PASSWORD= ``` -Suba a stack em seguida. Esse usuário e essa senha são o que o painel aceita. +Then bring the stack up. That user and that password are what the panel accepts. -### Subiu sem definir senha e agora não entra? +### Did not set a password, and now cannot get in? -No primeiro boot com `DASHBOARD_PASSWORD` vazio, o container gera uma -**credencial de recuperação** e a grava dentro do diretório de dados. Leia com: +On first boot with `DASHBOARD_PASSWORD` empty, the container generates a +**recovery credential** and writes it inside the data directory. Read it: ```bash -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` -Entre como `admin` com esse valor e defina a sua senha pela tela. A credencial -de recuperação continua valendo depois disso — ela é o arrombamento de vidro, e -uma que parasse de funcionar assim que você define uma senha seria inútil -justamente quando é necessária. +Sign in as `admin` with that value, then set a real password on the screen. The +recovery credential keeps working afterwards — it is break-glass, and one that +stopped working the moment you set a password would be useless exactly when you +need it. -> **English:** the panel asks for user and password. The user is `admin` (or -> whatever `DASHBOARD_USER` says) and the password is the one **you** set in -> `DASHBOARD_PASSWORD` — there is no factory password, because a fixed value -> shipped in an image is a public credential. If you brought the stack up -> without setting one, use the command above to read the recovery credential. +> **Português:** o painel pede usuário e senha. O usuário é `admin` (ou o que +> estiver em `DASHBOARD_USER`) e a senha é a que **você** definir em +> `DASHBOARD_PASSWORD` no `.env` — não existe senha de fábrica, porque um valor +> fixo publicado na imagem é uma credencial pública. Se subiu sem definir senha, +> use o comando acima para ler a credencial de recuperação e entre com ela. -## Como Executar via Docker +## Running with Docker -### Configuração: `.env` a partir do exemplo +### Configuration: the `.env` from the example -A configuração inteira vem de variáveis de ambiente, lidas de um `.env` ao lado -do `docker-compose.yml` — o Compose o encontra sozinho, sem nenhuma flag. +The whole configuration comes from environment variables, read from an `.env` +sitting next to `docker-compose.yml` — Compose finds it on its own, with no flag. ```bash -make setup # cria o .env a partir do .env.example, sem sobrescrever um existente +make setup # creates the .env from .env.example, never overwriting an existing one ``` -O alvo lista, ao final, exatamente quais variáveis ficaram em branco e precisam -ser preenchidas. Preencha e suba a stack. +The target finishes by listing exactly which variables were left blank and need +to be filled in. Fill them in, then bring the stack up. -O `.env` **nunca** é versionado, e o `.env.example` não carrega nenhum valor de -segredo — um valor publicado num arquivo de exemplo é, por definição, uma -credencial pública. Um teste garante que toda variável exigida por um compose -existe no exemplo, para que `cp .env.example .env` nunca produza um `.env` -incompleto. +The `.env` is **never** versioned, and `.env.example` carries no secret value — +a value published in an example file is, by definition, a public credential. A +test guarantees that every variable a compose file requires exists in the +example, so that `cp .env.example .env` never produces an incomplete `.env`. -### Portas, e por que cada uma é diferente +### Ports, and why each one differs -Os três sincronizadores escutam na **mesma porta dentro do container** (`9090`) -e publicam em portas diferentes no host, para que os três possam rodar lado a -lado. O mesmo vale para os gateways: cada um tem a sua. +The three synchronizers listen on the **same port inside the container** (`9090`) +and publish on different host ports, so that all three can run side by side. The +same goes for the gateways: each one has its own. -| Serviço | Porta interna | Publicada no host | +| Service | Internal port | Published on the host | | :--- | :--- | :--- | | 9Router | `20128` | `8081` | | OmniRoute | `20128` | `8082` | | LiteLLM | `4000` | `8083` | -| 9RTKSync (painel) | `9090` | `9091` | -| OminiRTkSync (painel) | `9090` | `9092` | -| LiteLlmRTKSync (painel) | `9090` | `9093` | +| 9RTKSync (dashboard) | `9090` | `9091` | +| OminiRTkSync (dashboard) | `9090` | `9092` | +| LiteLlmRTKSync (dashboard) | `9090` | `9093` | -A stack dos artigos (`claudegravity`) fica com a **`20128`**, a porta padrão do -9Router. As stacks dos repositórios saem dessa faixa de propósito: assim você -roda o artigo e os três sincronizadores ao mesmo tempo, sem conflito. +The article stack (`claudegravity`) keeps **`20128`**, the default 9Router port. +The repository stacks deliberately move out of that range: that way you can run +the article and all three synchronizers at the same time, with no conflict. -Tudo preso a `127.0.0.1`: o gateway carrega credenciais reais e não deve ficar -acessível na rede local. Para mudar qualquer uma, altere o lado esquerdo do -mapeamento no compose — o lado direito é a porta interna, que o processo escuta. +Everything is bound to `127.0.0.1`: the gateway carries real credentials and must +not be reachable on the local network. To change any of them, edit the left-hand +side of the mapping in the compose file — the right-hand side is the internal +port, the one the process listens on. -O pacote Docker oficial do OminiRTKSync é distribuído via GitHub Container Registry (GHCR): +Official multi-architecture Docker images (`linux/amd64` and `linux/arm64`) are published automatically to the GitHub Container Registry (GHCR): ```bash docker pull ghcr.io/pathbit/ominirtksync:latest ``` -### Exemplo no Docker Compose +### Docker Compose Example -Integre o `OminiRTKSync` ao seu `docker-compose.yml` junto ao [OmniRoute](https://github.com/diegosouzapw/OmniRoute): +Add `OminiRTKSync` to your `docker-compose.yml` alongside [OmniRoute](https://github.com/diegosouzapw/OmniRoute): ```yaml +name: ominirtksync-stack + services: - omniroute: + ominirtk-router: image: diegosouzapw/omniroute:latest - container_name: omniroute + container_name: ominirtk-router + hostname: ominirtk-router + networks: + - ominirtksync-net restart: unless-stopped ports: - # 20128 dentro do container; 8082 no host. + # Porta interna 20128 (padrao do OmniRoute); publicada em 8082 no host. - "127.0.0.1:8082:20128" environment: - DATA_DIR=/app/data @@ -149,34 +153,65 @@ services: - HOSTNAME=0.0.0.0 - NEXT_PUBLIC_BASE_URL=http://localhost:8082 - NODE_ENV=production - - INITIAL_PASSWORD=${INITIAL_PASSWORD:?defina no .env} - - JWT_SECRET=${JWT_SECRET:?openssl rand -hex 32} + # Sem valor de fallback: um default publicado em arquivo de exemplo vira + # a senha real de toda implantacao que 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 + # REQUIRE_LOGIN=false so e aceitavel porque a porta acima esta presa em + # 127.0.0.1. Ao expor 20128 na rede, mude para true. - REQUIRE_LOGIN=false volumes: - omniroute_data:/app/data - - ominirtksync: + # Sem este healthcheck o `condition: service_healthy` la embaixo nao tem o + # que esperar, e o compose recusa subir a stack inteira. + healthcheck: + test: + - CMD-SHELL + - >- + node -e "require('http').get('http://127.0.0.1:20128/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))" + interval: 15s + timeout: 5s + retries: 10 + # O primeiro boot roda as migracoes e cria o storage.sqlite; da folga. + start_period: 40s + + ominirtk-sync: + # Mesmo uid do gateway. Os dois compartilham o volume, e rodando como root + # os diretorios criados no startup nasciam com dono root: o omniroute -- + # que roda como `node` (1000) -- perdia a escrita no proprio volume. + user: "1000:1000" image: ghcr.io/pathbit/ominirtksync:latest - container_name: ominirtksync + container_name: ominirtk-sync + hostname: ominirtk-sync + networks: + - ominirtksync-net restart: unless-stopped ports: + # Porta interna 9090 (igual no 9RTKSync); publicada em 9092 no host. - "127.0.0.1:9092:9090" volumes: - omniroute_data:/app/data - ${HOME}:/root/host:ro + - ominirtksync_logs:/app/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=${OMNIROUTE_URL:-http://omniroute:20128} + - OMNIROUTE_URL=${OMNIROUTE_URL:-http://ominirtk-router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - WEB_PORT=${WEB_PORT:-9090} - DASHBOARD_USER=${DASHBOARD_USER:-admin} - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} + - LOG_DIR=${LOG_DIR:-/app/logs} + - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} depends_on: - - omniroute + ominirtk-router: + # Esperar o gateway ficar saudavel, e nao apenas iniciado: o OmniRoute + # cria o storage.sqlite durante o proprio boot, e subir antes disso faz + # o primeiro ciclo encontrar o banco ausente. + condition: service_healthy healthcheck: test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] interval: 15s @@ -186,23 +221,31 @@ services: volumes: omniroute_data: + ominirtksync_logs: + +networks: + ominirtksync-net: + name: ominirtksync-net + # Rede propria da stack. Na rede default, duas stacks no mesmo daemon + # resolvem o mesmo nome curto e nao da para saber a qual gateway o + # sincronizador se conectou. ``` --- -## Como Executar Localmente em Virtual Environment +## Local Development in Virtual Environment -Para executar diretamente no host utilizando [Python 3.14.7](https://www.python.org/ftp/python/3.14.7/python-3.14.7-macos11.pkg): +Following standard environment isolation, local runs strictly use a Python virtual environment with [Python 3.14.7](https://www.python.org/ftp/python/3.14.7/python-3.14.7-macos11.pkg): -### 1. Clonar o Repositório +### 1. Clone the Repository ```bash git clone https://github.com/pathbit/OminiRTkSync.git cd OminiRTkSync ``` -### 2. Criar e Ativar o Virtual Environment - +### 2. Create and Activate the Virtual Environment + ```bash python3 -m venv .venv source .venv/bin/activate @@ -210,84 +253,103 @@ pip install --upgrade pip pip install -e . ``` -### 3. Configurar Variáveis de Ambiente (.env) +### 3. Configure Environment Variables (.env) -Copie o modelo oficial para criar seu `.env` local (o arquivo `.env` é estritamente ignorado no git): +Copy the official template to create your local `.env` file (the `.env` file is strictly ignored by git): ```bash cp .env.example .env ``` -### 4. Comandos Disponíveis +### 4. Available CLI Commands ```bash -# Exibir status das conexões do OmniRoute -OminiRTKSync --status --db-path /caminho/para/storage.sqlite +# View current status of OmniRoute connections +OminiRTKSync --status --db-path /path/to/storage.sqlite -# Executar uma rodada única imediata de sincronização -OminiRTKSync --once --db-path /caminho/para/storage.sqlite +# Run an immediate one-shot synchronization pass +OminiRTKSync --once --db-path /path/to/storage.sqlite -# Executar em modo daemon contínuo com dashboard web -OminiRTKSync --daemon --db-path /caminho/para/storage.sqlite +# Run continuous background daemon with web dashboard on port 9090 (published on 9092) +OminiRTKSync --daemon --db-path /path/to/storage.sqlite ``` --- -## Variáveis de Ambiente +## Environment Variables -| Variável | Padrão | Descrição | +| Variable | Default | Description | | :--- | :--- | :--- | -| `DB_PATH` | `/app/data/storage.sqlite` | Caminho do arquivo SQLite do OmniRoute | -| `OMNIROUTE_URL` | `http://127.0.0.1:20128` | URL base do gateway OmniRoute para testes de conectividade | -| `SYNC_INTERVAL` | `300` | Intervalo em segundos entre varreduras no modo daemon e cron | -| `REFRESH_MARGIN` | `900` | Margem prévia em segundos para renovação de tokens | -| `ENABLE_WEB_DASHBOARD` | `1` | Ativa o dashboard web embutido (`1` para sim, `0` para não) | -| `WEB_PORT` | `9090` | Porta do dashboard web HTTP | -| `WEB_HOST` | `0.0.0.0` | Interface de rede para o servidor web | -| `DASHBOARD_USER` | `admin` | Usuário de autenticação HTTP Basic Auth | -| `DASHBOARD_PASSWORD` | *(vazio)* | Senha do painel. Vazia, o primeiro acesso usa a credencial de recuperação gerada no primeiro boot. | -| `ANTIGRAVITY_TOKEN_PATH` | auto | Caminho customizado para arquivo de token do Antigravity | +| `DB_PATH` | `/app/data/storage.sqlite` | Absolute path to the OmniRoute SQLite database | +| `OMNIROUTE_URL` | `http://127.0.0.1:20128` | Base URL of the OmniRoute gateway for diagnostics and integration | +| `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` | `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` | *(empty)* | 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 | --- -## Dashboard Web +## Web Dashboard -Com `ENABLE_WEB_DASHBOARD=1`, acesse no navegador: +When running with `ENABLE_WEB_DASHBOARD=1`, access the dashboard in your browser: 👉 **http://localhost:9092** -Recursos do painel: -* Monitoramento de todas as conexões cadastradas no OmniRoute. -* Estado de ativação de chaves de API e contas OAuth 2.0. -* Disparo de sincronização imediata via API REST (`POST /api/sync`). +Dashboard capabilities: +* Operational metrics (Total Connections, OAuth Accounts, API Keys, Resilience Combos). +* Six domain cards, in the same order as the sibling panels: gateway connection, scheduler, monitored connections, virtual keys, registered models, resilience combos. +* Activation state of API keys and OAuth 2.0 accounts, with the remaining validity of every connection. +* Gateway diagnostic card with millisecond latency testing, behind the **Test connection** button. +* Password change modal for credential rotation. +* The page is rendered on the server, and every button is a real request that redirects back to the freshly rendered page (POST-Redirect-GET, through `/acoes/`). The JSON endpoints are kept for automation: `POST /api/sync` and `POST /api/cron-run` trigger a pass, `GET /api/status` and `GET /api/cron-status` report state. --- -## Testes Unitários +## Unit and Integration Testing -Execute a suíte de testes completa dentro do virtual environment: +You can run the test suite inside your virtual environment, or bring up the live +bench to check the whole stack end to end. + +### Option 1. Local Virtual Environment ```bash source .venv/bin/activate +make test +# Or directly PYTHONPATH=src python3 -m unittest discover -s tests -p "test_*.py" ``` +### Option 2. Live Bench with Docker + +`docker-compose.test.yml` has no test-runner service: it is a live bench (a real +OmniRoute gateway plus this synchronizer) on its own ports and container names, +so it coexists with any other stack on the same machine. + +```bash +docker compose -f docker-compose.test.yml up -d +docker compose -f docker-compose.test.yml down -v +``` + --- -## Contribuição e Proteção da Branch Master +## Contributing and Branch Protection -* A branch `master` é protegida. Toda contribuição deve ser enviada via Pull Request e passar pela suíte de integração contínua. -* Questões e sugestões podem ser submetidas em [Issues](https://github.com/pathbit/OminiRTkSync/issues). -* Referência oficial do projeto base: [OmniRoute no GitHub](https://github.com/diegosouzapw/OmniRoute). +* The `master` branch is protected. All contributions must be submitted through Pull Requests and pass all CI checks. +* For bug reports or new provider requests, please open an issue in [GitHub Issues](https://github.com/pathbit/OminiRTkSync/issues). +* Official upstream gateway: [OmniRoute on GitHub](https://github.com/diegosouzapw/OmniRoute). --- -## 📄 Licença +## 📄 License -Distribuído sob a Licença MIT. O texto completo está em [LICENSE](https://github.com/pathbit/OminiRTkSync/blob/master/LICENSE). +Distributed under the MIT License. The full text is available in [LICENSE](https://github.com/pathbit/OminiRTkSync/blob/master/LICENSE). -Na prática: use, copie, altere e redistribua à vontade, inclusive comercialmente, desde que o aviso de copyright e a licença acompanhem as cópias. O software é fornecido como está, sem garantias. +In practice: use, copy, modify, and distribute freely, including commercially, provided that copyright and license notices accompany copies. The software is provided as is, without warranty. --- -Desenvolvido com ❤️ pela [Pathbit](https://pathbit.co/) +Developed with ❤️ by [Pathbit](https://pathbit.co/) diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index c7a596b..abbb734 100644 --- a/docker-compose.egress-test.yml +++ b/docker-compose.egress-test.yml @@ -1,4 +1,4 @@ -name: egress-test +name: ominirtk-egress # Bancada para testar POR ONDE o trafego sai. # @@ -21,36 +21,44 @@ services: # se o vinculo por conta realmente separa as saidas. proxy-a: image: ubuntu/squid:latest - container_name: egress-proxy-a + container_name: ominirtk-proxy-a restart: unless-stopped ports: - - "127.0.0.1:18081:3128" + - "127.0.0.1:18091:3128" networks: egress: - ipv4_address: 172.31.0.11 + ipv4_address: 172.32.0.11 healthcheck: # O que importa e a porta aceitar conexao: squidclient nao existe nesta # imagem, e um healthcheck que depende de binario ausente fica eternamente # "starting" sem que nada esteja errado. - test: ["CMD-SHELL", "timeout 2 bash -c '/dev/null || exit 1"] + test: + [ + "CMD-SHELL", + "timeout 2 bash -c '/dev/null || exit 1", + ] interval: 5s timeout: 3s retries: 10 proxy-b: image: ubuntu/squid:latest - container_name: egress-proxy-b + container_name: ominirtk-proxy-b restart: unless-stopped ports: - - "127.0.0.1:18082:3128" + - "127.0.0.1:18092:3128" networks: egress: - ipv4_address: 172.31.0.12 + ipv4_address: 172.32.0.12 healthcheck: # O que importa e a porta aceitar conexao: squidclient nao existe nesta # imagem, e um healthcheck que depende de binario ausente fica eternamente # "starting" sem que nada esteja errado. - test: ["CMD-SHELL", "timeout 2 bash -c '/dev/null || exit 1"] + test: + [ + "CMD-SHELL", + "timeout 2 bash -c '/dev/null || exit 1", + ] interval: 5s timeout: 3s retries: 10 @@ -60,13 +68,13 @@ services: # direto da maquina. echo: image: python:3.12-alpine - container_name: egress-echo + container_name: ominirtk-echo restart: unless-stopped ports: - - "127.0.0.1:18080:8080" + - "127.0.0.1:18090:8080" networks: egress: - ipv4_address: 172.31.0.20 + ipv4_address: 172.32.0.20 command: - python3 - -c @@ -93,13 +101,20 @@ services: ThreadingHTTPServer(("0.0.0.0", 8080), Echo).serve_forever() healthcheck: - test: ["CMD-SHELL", "python3 -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/',timeout=2)\""] + test: + [ + "CMD-SHELL", + 'python3 -c "import urllib.request;urllib.request.urlopen(''http://127.0.0.1:8080/'',timeout=2)"', + ] interval: 5s timeout: 3s retries: 10 networks: egress: + # Nome declarado, e nao derivado do nome do projeto: sem isto a rede nasce + # como `ominirtk-egress_egress` e o endereco depende do diretorio. + name: ominirtk-egress-net ipam: config: - - subnet: 172.31.0.0/24 + - subnet: 172.32.0.0/24 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 699a05d..7ac13b7 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,9 +1,16 @@ name: ominirtksync-stack services: - omniroute: + ominirtk-router: image: diegosouzapw/omniroute:latest - container_name: omniroute + container_name: ominirtk-router + hostname: ominirtk-router + networks: + # Duas redes de proposito (ver o bloco `networks:` no fim do arquivo): + # a propria, de gestao, onde so o sincronizador desta stack o alcanca; e + # a de inferencia, onde o LiteLLM o encontra pelo nome `ominirtk-router`. + - ominirtksync-net + - rtk-inference-net restart: unless-stopped ports: # Porta interna 20128 (padrao do OmniRoute); publicada em 8082 no host. @@ -15,7 +22,7 @@ services: - DATA_DIR=/app/data - PORT=20128 - HOSTNAME=0.0.0.0 - - NEXT_PUBLIC_BASE_URL=http://localhost:20128 + - NEXT_PUBLIC_BASE_URL=http://localhost:8082 - NODE_ENV=production # 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 @@ -42,9 +49,18 @@ services: # O primeiro boot roda as migracoes e cria o storage.sqlite; da folga. start_period: 40s - ominirtksync: + ominirtk-sync: + # Mesmo uid do gateway. Os dois compartilham o volume, e este servico + # cria db/ e logs/ no startup: rodando como root, esses diretorios + # nasciam com dono root e o omniroute -- que roda como `node` (1000) -- + # perdia a escrita no proprio volume, recusando o login com + # "EACCES: permission denied, mkdir '/app/data/db/backups'". + user: "1000:1000" image: ghcr.io/pathbit/ominirtksync:latest - container_name: ominirtksync + container_name: ominirtk-sync + hostname: ominirtk-sync + networks: + - ominirtksync-net restart: unless-stopped ports: # Porta interna 9090 (igual no 9RTKSync); publicada em 9092 no host. @@ -53,11 +69,11 @@ services: volumes: - omniroute_data:/app/data - ${HOME}:/root/host:ro - - ominirtksync_logs:/app/data/logs + - ominirtksync_logs:/app/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=${OMNIROUTE_URL:-http://omniroute:20128} + - OMNIROUTE_URL=${OMNIROUTE_URL:-http://ominirtk-router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} @@ -67,6 +83,15 @@ services: # 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= + # Entrada federada (SSO). O interruptor de emergencia SO SERVE se chegar + # ao container: a documentacao manda subir com SSO_DISABLED=1 quando o + # provedor de identidade cai, e sem esta linha a variavel ficaria no .env + # do host sem efeito nenhum -- que e exatamente o defeito ja corrigido + # aqui uma vez, logo abaixo, com CREDENTIAL_CHECK_*. + - SSO_DISABLED=${SSO_DISABLED:-0} + # Segredo do cliente OAuth. Vazio por padrao: sem valor aqui, o painel usa + # o arquivo .sso_client_secret (modo 0600) gravado pela tela. + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} - CRON_ENABLED=${CRON_ENABLED:-1} # - CRON_INTERVAL=300 # herda SYNC_INTERVAL quando omitido # Validacao viva de credenciais: pergunta ao provedor se a chave ainda e @@ -75,22 +100,124 @@ services: # 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_DIR=${LOG_DIR:-/app/logs} - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - omniroute: + ominirtk-router: # Esperar o gateway ficar saudavel, e nao apenas iniciado: o OmniRoute # cria o storage.sqlite durante o proprio boot, e subir antes disso faz # o primeiro ciclo encontrar o banco ausente. condition: service_healthy healthcheck: - test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + 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 + # --- Acesso remoto (opcional, nao sobe por padrao) ----------------------- + # + # Os dois servicos abaixo so existem quando voce pede o perfil: + # docker compose --profile tunel up -d # URL publica via Cloudflare + # docker compose --profile tailnet up -d # so quem esta na sua tailnet + # + # ANTES DE LIGAR QUALQUER UM DOS DOIS, leia docs/wiki/Remote-Access.md. O + # resumo: com a porta presa em 127.0.0.1, REQUIRE_LOGIN=false e aceitavel + # porque so a sua maquina alcanca. No instante em que um tunel sobe, esse + # raciocinio se inverte -- e o proprio painel do gateway avisa isso em + # vermelho ("Change the default dashboard password before activating the + # tunnel"). + ominirtk-tunel: + # Um container em vez do botao do gateway: o botao instala o cloudflared + # DENTRO do container do gateway, que e efemero -- recriar a stack desfaz a + # instalacao. Aqui o tunel e uma peca declarada, que sobe e desce com o + # resto e deixa rastro no compose. + image: cloudflare/cloudflared:latest + container_name: ominirtk-tunel + hostname: ominirtk-tunel + profiles: ["tunel"] + restart: unless-stopped + # Sem TUNNEL_TOKEN: quick tunnel, URL aleatoria a cada subida, zero + # configuracao, boa para uma demonstracao. Com TUNNEL_TOKEN de um tunel + # nomeado: URL estavel e a possibilidade de por o Cloudflare Access na + # frente, que autentica ANTES de a requisicao chegar no gateway. + command: >- + tunnel --no-autoupdate + ${TUNNEL_TOKEN:+run --token ${TUNNEL_TOKEN}} + ${TUNNEL_TOKEN:---url http://ominirtk-router:20128} + networks: + - ominirtksync-net + depends_on: + - ominirtk-router + + ominirtk-tailnet: + # Diferenca que decide a escolha: o tunel da uma URL que QUALQUER UM com o + # endereco alcanca; a tailnet so admite dispositivo que voce cadastrou. + # Para um painel que le credenciais, a segunda e quase sempre a certa. + image: tailscale/tailscale:latest + container_name: ominirtk-tailnet + # UNICA excecao a regra "container_name igual ao hostname" desta stack, e de + # proposito: o hostname deste container vira o NOME DO NO na tailnet, ou seja, + # o endereco que voce digita no navegador (http://ominirtk:9090). Com + # `ominirtk-tailnet` viraria http://ominirtk-tailnet:9090 -- mais longo de + # digitar, e sem ganho nenhum, porque dentro da stack ninguem alcanca este + # container pelo nome: ele existe para expor o painel para FORA. + hostname: ominirtk + profiles: ["tailnet"] + restart: unless-stopped + environment: + # Gere em https://login.tailscale.com/admin/settings/keys (chave efemera, + # para o no sumir sozinho quando o container morrer). Sem `:?` de + # proposito: o compose interpola tudo ANTES de filtrar por perfil, entao + # exigir aqui cobraria a chave ate de quem nunca vai usar este perfil. + # Quem cobra e o proprio container, na partida, e so quando o perfil sobe. + - TS_AUTHKEY=${TS_AUTHKEY:-} + - TS_STATE_DIR=/var/lib/tailscale + - TS_USERSPACE=true + # Publica o painel e o gateway na tailnet, cada um na sua porta. + - TS_SERVE_CONFIG=/config/serve.json + volumes: + - ominirtk_tailnet:/var/lib/tailscale + networks: + - ominirtksync-net + volumes: + ominirtk_tailnet: omniroute_data: ominirtksync_logs: + +networks: + ominirtksync-net: + name: ominirtksync-net + # Rede propria da stack. Na rede default, duas stacks no mesmo + # daemon resolvem o mesmo nome curto e nao da para saber a qual + # gateway o sincronizador se conectou. + + # POR QUE SAO DUAS REDES + # + # A de cima e de GESTAO e continua isolada: so o sincronizador desta stack + # fala com este gateway. E isso que garante que o painel do OminiRTkSync + # nunca leia, sem querer, o gateway do irmao -- antes, com todos na rede + # default, o mesmo nome curto resolvia para stacks diferentes e ninguem + # sabia a qual gateway o painel estava conectado. Nao desfaca. + # + # Esta e de INFERENCIA e e compartilhada de proposito: e o unico lugar onde + # o LiteLLM (litellmrtk-router) e os gateways se enxergam, para que uma + # requisicao que chega no LiteLLM possa sair por + # http://ominirtk-router:20128/v1. So os gateways e o LiteLLM entram nela; + # os sincronizadores NAO -- eles nao tem o que fazer no caminho de + # inferencia, e mante-los de fora preserva o isolamento de gestao. + # + # `external: true` porque a rede e compartilhada pelas tres stacks: ela nao + # pertence a nenhuma, nasce e morre fora do ciclo de vida de qualquer uma: + # docker network create rtk-inference-net + rtk-inference-net: + name: rtk-inference-net + external: true diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 72ecca5..02fdcd4 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,9 +12,12 @@ name: ominirtksync-test services: - omniroute: + ominirtk-test-router: image: diegosouzapw/omniroute:latest - container_name: ominirtksync-test-gateway + container_name: ominirtk-test-router + hostname: ominirtk-test-router + networks: + - ominirtksync-test-net restart: unless-stopped ports: - "127.0.0.1:19129:20128" @@ -43,10 +46,13 @@ services: # O primeiro boot roda migracoes e cria o banco. start_period: 45s - ominirtksync: + ominirtk-test-sync: build: . image: ghcr.io/pathbit/ominirtksync:local - container_name: ominirtksync-test-sync + container_name: ominirtk-test-sync + hostname: ominirtk-test-sync + networks: + - ominirtksync-test-net restart: unless-stopped ports: # Porta interna 9090 nos tres sincronizadores; publicada em 19092 aqui. @@ -54,7 +60,7 @@ services: environment: - DATA_DIR=/app/data - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=http://omniroute:20128 + - OMNIROUTE_URL=http://ominirtk-test-router:20128 - WEB_PORT=9090 - SYNC_INTERVAL=60 - REFRESH_MARGIN=900 @@ -66,10 +72,16 @@ services: volumes: - gateway_data:/app/data depends_on: - omniroute: + ominirtk-test-router: 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)"] + 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 @@ -77,3 +89,10 @@ services: volumes: gateway_data: + +networks: + ominirtksync-test-net: + name: ominirtksync-test-net + # Rede propria tambem na stack de teste. Sem esta secao o compose sobe em + # `ominirtksync-test_default`, a rede implicita onde duas stacks do mesmo + # daemon resolvem o mesmo nome curto. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 30a5f67..174783c 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -29,14 +29,11 @@ state the gateway keeps about its own connections. | Module | Responsibility | | :--- | :--- | | `cli.py` | Argument parsing, bootstrap of logging and the recovery hash, entry points. | -| `daemon.py` | `SyncEngine.sync_all()` — one full pass over every connection. | | `cron.py` | Background scheduler; keeps per-cycle history with the actions each produced. | -| `database.py` | SQLite reads and writes against `provider_connections` and `combos`. | +| `identidade.py` | The only file that may differ from the sibling projects: name, colours, icon, ports, gateway. | +| `gateway.py` | Everything this synchronizer knows about the gateway: SQLite reads and writes, host credential discovery, one handler per credential family (Google, generic OAuth, API key, local), `SyncEngine.sync_all()` — one full pass over every connection — and the fallback combos kept registered and up to date. | | `models.py` | `ConnectionRecord` and its derived properties (`is_oauth`, `is_local`, `remaining_seconds`, `health_status`). | | `normalizer.py` | Credential-format self-healing and stale-lock removal. | -| `discovery.py` | Finds provider credentials on the host filesystem. | -| `providers/` | One handler per credential family: Google, generic OAuth, API key, local. | -| `combos.py` | Keeps the fallback combos registered and up to date. | | `web.py` | HTTP server, routing, actions. | | `render.py` | Server-side HTML rendering. | | `i18n.py`, `prefs.py` | Interface language and its SQLite persistence. | diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md index 47dd519..3217217 100644 --- a/docs/wiki/Authentication.md +++ b/docs/wiki/Authentication.md @@ -92,8 +92,8 @@ password. **Retrieving it later** ```bash -docker logs ominirtksync 2>&1 | grep "Recovery hash" -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker logs ominirtk-sync 2>&1 | grep "Recovery hash" +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` **Notes** diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 3aacf67..bc84a33 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -39,7 +39,7 @@ out and produce `BrokenPipeError` in the logs. | `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`). | +| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Sync 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. | @@ -109,7 +109,7 @@ No dashboard, no interactive setup, scheduler on a one-minute cadence, logs kept ```yaml environment: - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=http://omniroute:20128 + - OMNIROUTE_URL=http://ominirtk-router:20128 - SYNC_INTERVAL=60 - REFRESH_MARGIN=1200 - ENABLE_WEB_DASHBOARD=0 diff --git a/docs/wiki/Dashboard.md b/docs/wiki/Dashboard.md index a9594f5..d305e2b 100644 --- a/docs/wiki/Dashboard.md +++ b/docs/wiki/Dashboard.md @@ -15,8 +15,8 @@ Reachable at **http://localhost:9092** (internal port 9090), behind HTTP Basic A | 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**. | +| Scheduler card | State, next run, tokens renewed, last result, **Logs**. | +| Connections table | Provider, name, type, health, remaining validity, **Details** (button that opens the modal carrying the renewal diagnosis). | | Resilience combos | Registered combos and their model cascade. | --- @@ -29,8 +29,7 @@ Every control is a real HTTP request that redirects back to the freshly rendered | 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. | +| **Sync now** | Triggers one scheduler cycle immediately, then reports what changed. The run lands in the history alongside the automatic ones. | | **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 @@ -40,7 +39,8 @@ hits the server. ## Renewal diagnosis -The single most useful column. Previously the panel showed only `0 renewed`, with no way to tell +The single most useful piece of information, shown in the **details modal** each connection's +**Details** button opens. 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 | diff --git a/docs/wiki/Egress-Testing.md b/docs/wiki/Egress-Testing.md index 4e592e7..e0a8cf4 100644 --- a/docs/wiki/Egress-Testing.md +++ b/docs/wiki/Egress-Testing.md @@ -25,14 +25,14 @@ Three containers, none of which touch the internet: | Container | Address | Role | | :--- | :--- | :--- | -| `egress-proxy-a` | `172.31.0.11` | an HTTP proxy | -| `egress-proxy-b` | `172.31.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | -| `egress-echo` | `172.31.0.20` | the referee: answers with the source address it saw | +| `ominirtk-proxy-a` | `172.32.0.11` | an HTTP proxy | +| `ominirtk-proxy-b` | `172.32.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | +| `ominirtk-echo` | `172.32.0.20` | the referee: answers with the source address it saw | The referee is what makes this verifiable. It returns JSON: ```json -{"seen_from": "172.31.0.11", "via": "1.1 squid/6.13", "forwarded_for": "172.31.0.2"} +{"seen_from": "172.32.0.11", "via": "1.1 squid/6.13", "forwarded_for": "172.32.0.2"} ``` `seen_from` is the whole point — no guessing, no third-party IP service, no @@ -57,18 +57,18 @@ the request: ```bash # 1. put the gateway on the bench network -docker network connect egress-test_egress +docker network connect ominirtk-egress-net # 2. register the pool through the gateway's own API curl -s -X POST http://127.0.0.1:8082/api/settings/proxies \ -H 'Content-Type: application/json' \ - -d '{"name":"bench","proxyUrl":"http://172.31.0.11:3128","isActive":true,"strictProxy":true}' + -d '{"name":"bench","proxyUrl":"http://172.32.0.11:3128","isActive":true,"strictProxy":true}' # 3. bind it to a connection, then watch the proxy log while traffic flows -docker logs -f egress-proxy-a +docker logs -f ominirtk-proxy-a ``` -A line like `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the +A line like `172.32.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the gateway's container address going through the proxy — the binding works. Then stop the proxy and send traffic again. **If the request still succeeds, the @@ -78,8 +78,8 @@ gateway fell back to direct.** Against a running OmniRoute (read on 2026-09-12): -- the pool binding works: `docker logs egress-proxy-a` showed - `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.31.0.2` is the +- the pool binding works: `docker logs ominirtk-proxy-a` showed + `172.32.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.32.0.2` is the gateway's container; - the **pool test path** detects a dead proxy correctly: `{"ok":false,"error":"Proxy test timed out"}`. @@ -134,9 +134,9 @@ O passo 4 é o único que separa isolamento de aparência de isolamento. O script, como vem, dirige o `curl` — isso verifica a bancada. Para medir a decisão **do gateway**, configure o proxy nele e deixe-o fazer a requisição: conecte o container do gateway à rede da bancada, cadastre o pool pela API dele, -vincule a uma conexão e acompanhe `docker logs -f egress-proxy-a`. +vincule a uma conexão e acompanhe `docker logs -f ominirtk-proxy-a`. -Uma linha como `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o +Uma linha como `172.32.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o endereço do container do gateway passando pelo proxy — o vínculo funciona. Depois derrube o proxy e gere tráfego de novo. **Se a requisição ainda for @@ -147,7 +147,7 @@ atendida, o gateway caiu para saída direta.** Contra um OmniRoute em execução (lido em 12/09/2026): - o vínculo do pool funciona: o log do proxy registrou - `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`; + `172.32.0.2 TCP_TUNNEL/200 CONNECT google.com:443`; - o **caminho de teste do pool** detecta um proxy morto corretamente: `{"ok":false,"error":"Proxy test timed out"}`. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a3832b3..5f26cb5 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -24,6 +24,7 @@ republishes these pages automatically. Editing a page directly here will be over | [Remote Access](Remote-Access) | Tunnel, Tailscale, and what has to be on before either | | [Egress Testing](Egress-Testing) | A bench that proves where the traffic actually leaves from | | [Egress and Multi-Session](Egress-And-Multi-Session) | Why several accounts sharing one outbound address is the risk, and what the gateway models | +| [Licensing and Capacity](Licensing-And-Capacity) | How many subscriptions for how many developers — the formula, the measured demand, and the divisor nobody publishes | | [Troubleshooting](Troubleshooting) | Concrete symptoms and what they actually mean | | [Upstream Fixes](Upstream-Fixes) | Bugs found in the gateways and the patches sent upstream | @@ -84,7 +85,7 @@ credential on first boot — read it and sign in as `admin`, then set a real password on the screen: ```bash -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` Full detail in [Authentication](Authentication). diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index f4f7ba4..f2cd996 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -15,12 +15,15 @@ docker pull ghcr.io/pathbit/ominirtksync:latest A working `docker-compose.yml` alongside the gateway: ```yaml -name: omniroute-stack +name: ominirtksync-stack services: - omniroute: - image: diegosouzapw/OmniRoute:latest - container_name: omniroute + ominirtk-router: + image: diegosouzapw/omniroute:latest + container_name: ominirtk-router + hostname: ominirtk-router + networks: + - ominirtksync-net restart: unless-stopped ports: # 20128 dentro do container; 8082 no host. @@ -32,9 +35,12 @@ services: volumes: - omniroute_data:/app/data - ominirtksync: + ominirtk-sync: image: ghcr.io/pathbit/ominirtksync:latest - container_name: ominirtksync + container_name: ominirtk-sync + hostname: ominirtk-sync + networks: + - ominirtksync-net restart: unless-stopped ports: # Internal port 9090 (same in OminiRTKSync); published on 9092. @@ -47,16 +53,16 @@ services: environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=http://omniroute:20128 + - OMNIROUTE_URL=http://ominirtk-router:20128 - SYNC_INTERVAL=300 - REFRESH_MARGIN=900 - WEB_PORT=9090 - DASHBOARD_USER=admin - - DASHBOARD_PASSWORD=change-me + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} - LOG_DIR=/app/data/logs - LOG_RETENTION_DAYS=30 depends_on: - - omniroute + - ominirtk-router 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 @@ -67,6 +73,10 @@ services: volumes: omniroute_data: ominirtksync_logs: + +networks: + ominirtksync-net: + name: ominirtksync-net ``` Then open **http://localhost:9092**. @@ -137,8 +147,8 @@ make venv && make test ## Upgrading ```bash -docker compose pull ominirtksync -docker compose up -d ominirtksync +docker compose pull ominirtk-sync +docker compose up -d ominirtk-sync ``` State that survives upgrades lives in the data volume: `.dashboard_auth.json` (screen-set diff --git a/docs/wiki/Licensing-And-Capacity.md b/docs/wiki/Licensing-And-Capacity.md new file mode 100644 index 0000000..a43badb --- /dev/null +++ b/docs/wiki/Licensing-And-Capacity.md @@ -0,0 +1,1129 @@ +# Licensing and capacity: how many subscriptions for how many developers + +*(Versão em português ao final.)* + +The question always arrives as arithmetic. *We are twelve developers — how many +Max plans do we buy?* It sounds like a division, and it is. The trouble is that +**the divisor is not published by anybody**. + +Not one of the consumer subscriptions involved here states the absolute capacity +of a single seat. What they publish is a relative multiplier and a reset window. +So a table saying "1 licence covers 4 developers" could only be produced by +inventing the number nobody discloses — and the invented number would be +repeated for years by people who had no way to check it. + +This page does the three things that can honestly be done instead: + +1. it gives the **formula**, with every variable named; +2. it fills in the **demand** side with numbers that were actually measured; +3. it gives the **command** that finds the missing divisor in *your* install — + and, for this gateway, it shows where OmniRoute keeps a slot shaped exactly + like it. + +For the routing, credential and egress side of the same accounts, see +[Architecture](Architecture), [Authentication](Authentication) and +[Egress and Multi-Session](Egress-And-Multi-Session). + +--- + +## What the vendors publish, and what they withhold + +| Vendor | What is published | Absolute number? | +| :--- | :--- | :--- | +| Anthropic Pro/Max | "Your session-based usage limit will reset every five hours." · "Max 5x provides five times more usage per session than the Pro plan." · "Max 20x provides 20 times more usage per session than the Pro plan." · "Max plans also have a weekly usage limit that applies across all models." | **No.** Multiplier and window only. | +| Anthropic (limits page) | "Your usage is affected by several factors, including the length and complexity of your conversations, the features you use, which Claude model you're chatting with, and the effort level you've selected." | **No.** | +| OpenAI Codex | "Local messages and cloud chats share your plan's usage allowance. Weekly limits may also apply." · a "rolling five-hour period" · per-plan bands (Plus 10–100 / 25–200 / 250–2,000 messages depending on model) | **No** — the page itself says: "These estimates are not fixed message limits; check your usage dashboard for current limits and reset times." | +| Google Gemini API | "Rate limits depend on a variety of factors (such as your usage tier) and can be viewed in Google AI Studio." | **No.** Defers to the console. | +| Google Gemini Code Assist | Standard: **1,500** requests **per user per day** · Enterprise: **2,000** requests **per user per day** · **2** requests per second **per user** | **Yes — and per user.** | + +`[FONTE: https://support.claude.com/en/articles/11049741-what-is-the-max-plan — lido em 2026-09-12]` +`[FONTE: https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work — lido em 2026-09-12]` +`[FONTE: https://learn.chatgpt.com/docs/pricing — lido em 2026-09-12]` +`[FONTE: https://ai.google.dev/gemini-api/docs/rate-limits — lido em 2026-09-12]` +`[FONTE: https://docs.cloud.google.com/gemini/docs/quotas — lido em 2026-09-12]` + +The same Google page also lists "6000 requests per day for code generation and +completion" and "960 requests per day for chat and visualization in Cloud +Assist" **with no plan breakdown** — and the second is attributed to *Cloud* +Assist, not *Code* Assist. Quote them with that exact label or not at all. + +**The Code Assist row is the instructive one.** The one time a vendor does print +a number, it comes stamped *per user*. There is no pot of 1,500 requests that +twelve developers share; there are twelve pots of 1,500. That is not a wording +detail — it is the shape of the whole answer, and the terms-of-use section below +arrives at the same shape from a completely different direction. + +--- + +## The one place the number does exist: the API + +`[FONTE: https://platform.claude.com/docs/en/api/rate-limits — lido em 2026-09-12]` + +| Tier | RPM (Opus 5 / Sonnet 5) | Input tokens/min | Output tokens/min | Monthly spend cap | +| :--- | ---: | ---: | ---: | ---: | +| Start | 1,000 | 2,000,000 | 400,000 | US$ 500 | +| Build | 5,000 | 5,000,000 | 1,000,000 | US$ 1,000 | +| Scale | 10,000 | 10,000,000 | 2,000,000 | US$ 200,000 | + +Two warnings from the same page matter to a team that switches on all at once: + +> "New organizations and organizations with limited usage history may start in +> the **Evaluation tier, with limits below the standard limits** shown on this +> page." + +> "You might also encounter 429 errors because of **acceleration limits** on the +> API if your organization has a sharp increase in usage." + +Twelve developers onboarding on the same morning trip both. The tier computed +below is the steady-state tier, not the first-day tier. + +And the detail that changes the arithmetic by an order of magnitude: + +> "**For most Claude models, only uncached input tokens count toward your ITPM +> rate limits.**" + +So `input_tokens` counts, `cache_creation_input_tokens` counts, and +`cache_read_input_tokens` does **not**. In the measured history below, **98.3% of +all tokens moved are cache reads**, and the ratio between median total input and +median counting input is **21.1×**. Sizing the API path by summing cache reads +buys roughly twenty times more capacity than the workload needs. + +**Watch the scope of that rule.** It is stated on the **API** rate-limit page. +Nothing read here says the five-hour meter on a Pro/Max subscription ignores +cache reads. That is why the API table below uses uncached input and the +subscription table uses **total** tokens — and why what a subscription actually +counts stays an open measurement. + +--- + +## The formula + +| Symbol | Name | Unit | Where it comes from | +| :--- | :--- | :--- | :--- | +| `N` | developers on the team | people | headcount | +| `c` | concurrency factor | 0–1 | measure it — the fraction of `N` requesting at the same moment | +| `U_sim` | simultaneous active users | sessions | `U_sim = N × c` | +| `R_h` | requests per hour per active session | req/h | measured below | +| `T_in` | **uncached** input tokens per request | tokens | measured below — API path only | +| `T_tot` | **total** input tokens per request | tokens | measured below — subscription path | +| `T_out` | output tokens per request | tokens | measured below | +| `W_h` | quota reset window | hours | published: 5 h (Anthropic, Codex). The two-hour per-family reset observed on Antigravity is **a log observation**, not a published window `[FONTE: pathbit-ai-for-devs/0002_claude_gravity_utilizando_9router/article/ARTICLE.md:689]` | +| `F` | slack | dimensionless | an operations decision; 0.30 in every table here | +| `C_window` | capacity of **one** licence inside `W_h` | tokens or requests | **not published** — measure it, see below | + +``` +U_sim = N × c + +# API path — the ceiling ignores cache reads +D_api = U_sim × R_h × T_in × W_h × (1 + F) + +# subscription path — what the meter counts is unknown, so use the total +D_sub = U_sim × R_h × (T_tot + T_out) × W_h × (1 + F) + +L = max( ceil(D / C_window) , L_burst ) +``` + +Burst is checked separately, because a per-window quota and a per-minute ceiling +are different ceilings and the second one blows first: + +``` +RPM_required = U_sim × R_h / 60 × (1 + F) +input_per_min = RPM_required × T_in +output_per_min = RPM_required × T_out +L_burst = max over the three of ceil(required / licence ceiling) +``` + +**The unit of `D` and the unit of `C_window` have to match.** If you calibrated +`C_window` against a subscription's usage bar, it came out in total tokens, so +`D` has to be `D_sub`. Mixing the two is the most likely silent error in this +whole method. + +--- + +## Measured demand + +**This block is a snapshot, not a constant.** Claude Code history is a living +corpus: it grows with every session, so the same script run tomorrow on the same +machine returns different numbers, and neither run is wrong. What makes a +published number checkable is the cut — session files are append-only, so +everything before a past instant stops moving. Hence `--until`, and hence the +full command: + +```bash +./.venv/bin/python tools/measure_agent_usage.py --until 2026-09-13T00:00:00Z +``` + +`[FONTE: saída do comando acima, histórico de UMA máquina, corte em 2026-09-13T00:00:00Z]` + +``` +historico lido : ~/.claude/projects/**/*.jsonl +corte (--until) : 2026-09-13T00:00:00Z +sessoes analisadas : 114 +turnos unicos (dedup por message.id) : 6648 +T_in entrada que conta p/ ITPM mediana : 4178 +T_in entrada que conta p/ ITPM p90 : 8227 +T_out saida mediana : 706 +T_out saida p90 : 1361 +T_cache leitura de cache mediana : 83413 +T_tot entrada total (conta+cache) mediana : 88353 +T_tot entrada total (conta+cache) p90 : 303214 +R_h requisicoes por hora ativa mediana : 194 +R_h requisicoes por hora ativa p90 : 343 +fracao de leitura de cache no total : 98.3% +razao entrada total / entrada que conta : 21.1x +pico de sessoes simultaneas : 13 +linhas de resposta (com usage) : 34305 +message.id distintos : 15905 +inflacao de contar linha e nao resposta : 2.16x +``` + +Run it **without** `--until` and you get today's history instead — larger, and +the right thing to use when you are sizing your own team. Run it with the cut +above **on the machine measured** and you get exactly the block printed here, +twice in a row. On your machine you get your own numbers, which is the point. + +Four caveats have to travel with those numbers, always: + +1. **Deduplication is not optional.** One API response is written to several + `type: "assistant"` lines — the text and each tool block — repeating the same + `message.id` and the same `usage` object. Counting lines inflates everything + by **2.16×** — 34,305 response lines against 15,905 distinct ids, the last + three lines of the output above. That factor is no longer a claim in prose: + the script counts it and prints it. Any consumption figure derived from Claude + Code history without deduplicating by `message.id` is wrong by roughly two. +2. **21.1× is a ratio of two medians**, not the median of the ratios. It is good + for an order of magnitude, not for accounting. +3. **The machine measured runs orchestration with subagents.** The peak of 13 + simultaneous sessions is one operator's parallelism, not a team's + concurrency. Treat `R_h ≈ 194 req/h` as an **agent session** — one request + every ~19 s — not as "a developer typing". Interactive CLI use without + subagents measures lower. Run the script on your own machine. +4. **It is one machine, one operator, one working style.** The cut makes the + number *auditable*; it does not make it *general*. Nothing here says your + history looks like this one — which is why every table below is a worked + example of the method, not a lookup table. + +--- + +## Sizing: subscription path + +This is the path OmniRoute is built for — it multiplexes **subscription +credentials**, one connection per account. Unit: **total tokens**, cache reads +included, per the rule above. `W_h = 5 h` is published; the other two inputs are +not measurements and are not dressed as such: + +> **`c = 0.6` and slack 30% are arbitrated.** Nobody measured them here. `c` is +> the fraction of the team requesting at the same instant, and the formula table +> above says plainly that it has to be measured — on your own team, by counting +> concurrent sessions, not by reading this page. It is printed at the top of +> `tools/sizing.py`'s output under the label `arbitrado` precisely so it never +> gets quoted as a finding. Section "`c` moves the answer more than `N` does" +> below is the reason to care: this is the single input that most deserves your +> own measurement. + +`[FONTE: `./.venv/bin/python tools/sizing.py`, com as entradas medidas acima e c/folga arbitrados]` + +| Profile | Developers | `U_sim` | Demand in one 5 h window | Licences | +| :--- | ---: | ---: | ---: | :--- | +| median | 3 | 1.8 | 202,146,118 tokens | `ceil(D / C_window)` | +| median | 12 | 7.2 | 808,584,473 tokens | `ceil(D / C_window)` | +| median | 40 | 24.0 | 2,695,281,576 tokens | `ceil(D / C_window)` | +| p90 | 3 | 1.8 | 1,222,289,932 tokens | `ceil(D / C_window)` | +| p90 | 12 | 7.2 | 4,889,159,730 tokens | `ceil(D / C_window)` | +| p90 | 40 | 24.0 | 16,297,199,100 tokens | `ceil(D / C_window)` | + +The right column stays symbolic **because no vendor publishes `C_window`**. Fill +it in with your own measurement and the division closes in one line. This is +exactly where a method differs from an invented table: the method says what is +missing, in which unit, and how to obtain it. + +## Sizing: API-key path + +OmniRoute also holds plain API keys — four of the five connections in the +install inspected here are keys, not OAuth accounts (the query and its output are +both in the quota-subsystem section below). For those the ceiling **is** +published, so the table +resolves. Unit: **uncached input tokens**. Slack 30%, `c` arbitrated as above and +varied on purpose to show how much it moves. + +| Profile | Developers | `c` | `U_sim` | RPM | Input/min | Output/min | Minimum tier | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | :--- | +| median | 3 | 0.6 | 1.8 | 8 | 31,611 | 5,342 | Start | +| median | 12 | 0.6 | 7.2 | 30 | 126,443 | 21,366 | Start | +| median | 12 | 1.0 | 12.0 | 50 | 210,738 | 35,611 | Start | +| median | 40 | 0.4 | 16.0 | 67 | 280,984 | 47,481 | Start | +| median | 40 | 0.6 | 24.0 | 101 | 421,477 | 71,221 | Start | +| median | 40 | 1.0 | 40.0 | 168 | 702,461 | 118,702 | Start | +| p90 | 3 | 0.6 | 1.8 | 13 | 110,053 | 18,206 | Start | +| p90 | 12 | 0.6 | 7.2 | 54 | 440,210 | 72,824 | Start | +| p90 | 40 | 0.6 | 24.0 | 178 | 1,467,368 | 242,748 | Start | +| p90 | 40 | 1.0 | 40.0 | 297 | 2,445,613 | 404,580 | **Build** | + +Three things to read out of it: + +- **A forty-person team on the median profile still fits inside the Start tier.** + Only the extreme corner — forty developers, all concurrent, on the p90 profile + — needs Build, and what pushes it there is input tokens per minute (2.45 M + against 2.00 M), not requests per minute. +- **The burst ceiling is rarely what hurts.** Against Start's 1,000 RPM, three + developers at `c = 0.6` need 8 RPM (132× slack), twelve need 30 (33×), forty + need 101 (9×). +- **`c` moves the answer more than `N` does.** Forty developers at `c = 0.4` and + twelve at `c = 1.0` land in the same tier. Measuring concurrency is worth more + than counting chairs. + +--- + +## What this synchronizer shows you about it + +The capacity question reaches the panel through exactly one chain, and it is +worth naming each link, because the field names differ at every step. + +**The saturation signal.** OmniRoute stores the hold as a column, +`rate_limited_until TEXT` on `provider_connections` — or as `rateLimitedUntil` +inside the JSON `data` column on installs migrated from the single-column schema. +`get_all_connections` projects both onto one key, `rateLimitedUntil` +(`src/omini_rtksync/gateway.py:193`). `ConnectionRecord.rate_limit_active` +reads it as **a deadline, not a flag** (`src/omini_rtksync/models.py:157-169`) — it +holds the instant the provider's window reopens, so treating the field's mere +presence as "limited" left a connection yellow forever after its first 429. +When the deadline is still in the future, `health_status` returns +`rate_limited`, which the panel paints as the **Rate limited** badge in the +health column of the connections table (`src/omini_rtksync/i18n.py:92`, +`src/omini_rtksync/render.py:36`). See [Dashboard](Dashboard) for where that +column sits. + +**The counter that closes the loop.** When the deadline passes, the sync clears +the hold and records the action as `Trava de rate limit vencida removida` +(`src/omini_rtksync/cli.py:143-149`). Those entries accumulate in the scheduler +**Logs** modal, one per cycle. Counting them per account per day for a week is +the only evidence that actually settles whether `L` was right: an account that +gets held every day is undersized; an account that never gets held is slack you +can put more people on. Everything before this section is projection. + +**The headcount.** The metric cards carry **OAuth accounts** and **API keys** +(`src/omini_rtksync/i18n.py:35-36`). The first is the `L` of the terms-of-use +section below — accounts with their own account holder. The second is the +API-key path, which is sized by tier rather than by seat. + +**The cheapest lever.** **Registered combos** and the **Resilience combos** +section list each combo and its model cascade, read from OmniRoute's `combos` +table as `name`, `kind` and `models` +(`src/omini_rtksync/gateway.py:449-473`). This matters more than it looks: +quota can be exhausted **per model family** while the account itself stays +healthy. During an Antigravity block, every Gemini-family model returned 503 +while Claude and the open-weight models on the same account kept answering +`[FONTE: pathbit-ai-for-devs/0002_claude_gravity_utilizando_9router/article/ARTICLE.md:684-720]`. +A fallback combo that crosses families therefore multiplies effective capacity +**without buying a licence** — and it is the only lever here that does not run +into the terms-of-use limit below. The install inspected had **0 combos** +registered — the count command and its full output are in the quota-subsystem +section below. + +**Automation.** `/api/status` returns `connectionsCount` and `combosCount` +(`src/omini_rtksync/web.py:401-412`), and the command line prints the same +inventory: + +```bash +ominirtksync --status --db-path ~/.omniroute/data/storage.sqlite +``` + +The product tries five paths, in this order, and takes the first that exists: +`/app/data/storage.sqlite`, `/app/data/data.sqlite`, +`~/.omniroute/data/storage.sqlite`, `~/.omniroute/storage.sqlite`, +`~/.omniroute/data.sqlite` +`[FONTE: src/omini_rtksync/config.py:221-227]`. So the `data/` segment is not +required — it simply comes first. Write the path your install actually has; on +a container install that is the first, on a local one usually the third. + +**What the panel does not show, and will not pretend to:** token counts, a +percentage of quota consumed, the remaining window, or `max_concurrent`. The +synchronizer reads credential health; it is not a metering product. + +### One gap worth naming + +`provider_connections` also carries `backoff_level INTEGER DEFAULT 0` +`[FONTE: schema lido de diegosouzapw/omniroute:latest em execução, 2026-09-12]`. +The sync clears `rate_limited_until` when the deadline passes +(`src/omini_rtksync/gateway.py:376-378`) but **never resets `backoff_level`** — +there is no occurrence of the string anywhere under `src/`. The sibling project +9RTKSync does zero its equivalent when it clears the hold +`[FONTE: 9RTKSync/src/nine_rtksync/normalizer.py:87 — `data["backoffLevel"] = 0`]`. +Whether OmniRoute decays the level on its own is `[A VERIFICAR: leia o +tratamento de backoff_level no fonte do gateway]`. It is recorded here because a +stale backoff level would make an account look more saturated than it is, which +is precisely the kind of error this page exists to avoid. + +--- + +## The slot shaped like `C_window` — present, and partly filled + +This is the genuinely interesting find about OmniRoute, and it needs to be +stated without overclaiming. + +The shipped schema contains a full quota subsystem. Schema, unlike a row count, +is a durable property — it comes from the image, not from the traffic — and it is +one command away `[FONTE: `sqlite3 /tmp/omniroute.sqlite "SELECT sql FROM +sqlite_master WHERE name='provider_quota_state';"` contra +`diegosouzapw/omniroute:latest`, 2026-09-13]`: + +```sql +CREATE TABLE provider_quota_state ( + connection_id TEXT NOT NULL, + model TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + token_limit INTEGER NOT NULL DEFAULT 0, + window_start INTEGER NOT NULL, + window_reset INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (connection_id, model) +); +``` + +`token_limit` per connection *per model*, with the window boundaries next to it, +is `C_window` in database form — and at the granularity the Antigravity +observation says it needs, which is per model family rather than per account. +Alongside it sit `quota_snapshots` (`window_key`, `remaining_percentage`, +`is_exhausted`, `next_reset_at`, `window_duration_ms`, `raw_data`), +`provider_plans` (`dimensions_json`, `source` constrained to `auto` or `manual`), +and `quota_pools` with `quota_pool_connections`. Note `window_key`: it is +`quota_snapshots`' own per-model dimension, and it matters below. + +**Counting them takes two commands, and the second one is the one that is easy +to get wrong.** The container ships no `sqlite3`, and the database runs in WAL +mode — so a `docker cp` of `storage.sqlite` alone reads a stale file and +undercounts whatever is still in the write-ahead log. Measured on one single +copy, read twice: **188 `call_logs` without the `-wal` beside it, 209 with it**. +Twenty-one rows, 10% of the table, invisible to the shorter command — and +silently, since nothing errors. Copy the `-wal` with it: + +```bash +docker cp ominirtk-router:/app/data/storage.sqlite /tmp/omniroute.sqlite +docker cp ominirtk-router:/app/data/storage.sqlite-wal /tmp/omniroute.sqlite-wal + +sqlite3 -header -column /tmp/omniroute.sqlite " +SELECT 'provider_quota_state' AS tabela, count(*) AS linhas FROM provider_quota_state +UNION ALL SELECT 'quota_snapshots', count(*) FROM quota_snapshots +UNION ALL SELECT 'provider_plans', count(*) FROM provider_plans +UNION ALL SELECT 'quota_pools', count(*) FROM quota_pools +UNION ALL SELECT 'daily_usage_summary', count(*) FROM daily_usage_summary +UNION ALL SELECT 'usage_history', count(*) FROM usage_history +UNION ALL SELECT 'call_logs', count(*) FROM call_logs +UNION ALL SELECT 'combos', count(*) FROM combos +UNION ALL SELECT 'provider_connections', count(*) FROM provider_connections;" +``` + +`[FONTE: saída do comando acima contra `diegosouzapw/omniroute:latest` em execução, instante 2026-09-13T02:20:53Z]` + +``` +tabela linhas +-------------------- ------ +provider_quota_state 0 +quota_snapshots 48 +provider_plans 0 +quota_pools 0 +daily_usage_summary 0 +usage_history 8 +call_logs 209 +combos 0 +provider_connections 5 +``` + +**Read that table as an instant, not as a property.** `call_logs` and +`quota_snapshots` climb as traffic goes through: on this same container, +Run the command twice, some minutes apart, and the two counts differ — that is +the point, and it is the only evidence anybody needs here. Earlier readings of +this same install are not reproducible by you and so are not quoted. The zeros are the durable part — and the 48 is the +interesting part, because an earlier reading of this same install found +`quota_snapshots` at 0 and the page concluded, wrongly, that nothing ever fills +it. What actually happened is that the first snapshot was written at +23:29:23.587Z, *after* the last `usage_history` row at 22:54Z. The measurement was +right; the conclusion drawn from it was not. + +So the honest finding is the opposite of "empty": + +```bash +sqlite3 -header -column /tmp/omniroute.sqlite " +SELECT c.provider, c.auth_type, count(s.id) AS snapshots, + count(DISTINCT s.window_key) AS modelos, + min(s.created_at) AS primeiro, max(s.created_at) AS ultimo +FROM provider_connections c LEFT JOIN quota_snapshots s ON s.connection_id = c.id +GROUP BY c.id ORDER BY snapshots DESC;" +``` + +``` +provider auth_type snapshots modelos primeiro ultimo +----------- --------- --------- ------- ------------------------ ------------------------ +antigravity oauth 48 16 2026-09-12T23:29:23.587Z 2026-09-13T01:55:03.806Z +openrouter apikey 0 0 +mistral apikey 0 0 +groq apikey 0 0 +gemini apikey 0 0 +``` + +**OmniRoute fills `quota_snapshots` by itself, per window key, on the OAuth +connection — and only there.** Sixteen distinct `window_key` values on one +account: model names (`claude-sonnet-4-6`, `gemini-3.1-pro-high`, +`gpt-oss-120b-medium`) next to aggregate keys (`claude_gpt_weekly`), plus two +(`chat_20706`, `chat_23310`) that look like per-conversation counters and fit +neither description. The four +API-key connections have none. That is the per-model dimension, populated, +without anybody declaring a plan in `provider_plans` first. + +**And it still is not `C_window`.** Every one of the 48 rows reads +`remaining_percentage = 100.0`, `is_exhausted = 0`, with `window_duration_ms` and +`raw_data` NULL throughout — an account nobody has worked hard enough to dent. +More important than the emptiness of the numbers is their *unit*: a percentage is +`1 − p`, the consumed fraction. It is the left-hand side of the calibration +procedure at the end of this page, not its answer. To get `C_window` in tokens +you still have to pair that percentage with the tokens you actually moved in the +same window. What OmniRoute gives you for free is the `p` — read by machine, per +model, instead of eyeballed off a progress bar. That is a real shortcut, and it +is worth exactly that much. + +`provider_quota_state` — the table that carries `token_limit`, the absolute +number — is still at 0. Whether OmniRoute ever writes it, and from which upstream +response, is `[A VERIFICAR: leia no fonte do gateway quem escreve em +provider_quota_state, e se depende de um plano declarado em provider_plans]`. +This synchronizer reads none of these tables: `git grep -n +'quota_snapshots\|provider_quota_state' -- src/` returns nothing. + +The same reservation applies to the per-connection knobs +`max_concurrent INTEGER` and `rate_limit_protection INTEGER DEFAULT 0`, which +are where `c` would be materialised per account: + +```bash +sqlite3 -header -column /tmp/omniroute.sqlite " +SELECT provider, auth_type, max_concurrent, rate_limit_protection, backoff_level, + expires_in +FROM provider_connections;" +``` + +``` +provider auth_type max_concurrent rate_limit_protection backoff_level expires_in +----------- --------- -------------- --------------------- ------------- ---------- +antigravity oauth 0 0 3599 +groq apikey 0 0 +openrouter apikey 0 0 +gemini apikey 0 0 +mistral apikey 0 0 +``` + +`[FONTE: saída do comando acima, mesmo instante 2026-09-13T02:20:53Z]` — one OAuth +account against four API keys, `max_concurrent` NULL on all five, +`rate_limit_protection` 0 on all five. The knob exists and nobody turned it. + +A note on granularity, since the sibling documentation reads differently: 9Router +keeps per-family holds as `modelLock_*` keys inside the connection JSON. **There +is no `modelLock_*` in OmniRoute.** Here the *hold* is one deadline for the whole +connection (`rate_limited_until`) — but do not conclude from that, as an earlier +version of this page did, that per-model granularity is absent. It is not: it +lives in `quota_snapshots.window_key`, which is populated, and it would live in +`provider_quota_state.model`, which is not. Two different tables, two different +answers, and only one of them empty. Do not port the sibling's paragraph across, +and do not port this one back. + +--- + +## Measure demand here, not only on the developer's laptop + +The script above reads Claude Code history on one machine. OmniRoute has +something better for a team, because it is per account: `usage_history` records +every call that went through the gateway with the exact token split the formula +needs `[FONTE: schema lido de diegosouzapw/omniroute:latest em execução, +2026-09-12]`. + +```bash +# Instalação local; num deploy em contêiner, copie primeiro como na seção acima +# (com o `-wal`, ou a contagem sai menor do que é). +sqlite3 -header -column ~/.omniroute/data/storage.sqlite " +SELECT provider, + count(*) AS requisicoes, + sum(tokens_input + tokens_cache_creation) AS entrada_que_conta, + sum(tokens_cache_read) AS leitura_de_cache, + sum(tokens_output) AS saida, + min(timestamp) AS de, + max(timestamp) AS ate +FROM usage_history GROUP BY provider ORDER BY requisicoes DESC;" +``` + +Run against the live database, it answers: + +``` +provider requisicoes entrada_que_conta leitura_de_cache saida de ate +---------- ----------- ----------------- ---------------- ----- ------------------------ ------------------------ +gemini 4 18 0 237 2026-09-12T22:47:04.218Z 2026-09-12T22:54:42.006Z +mistral 2 18 0 4 2026-09-12T22:47:03.788Z 2026-09-12T22:54:39.507Z +groq 1 18 0 2 2026-09-12T22:47:42.194Z 2026-09-12T22:47:42.194Z +openrouter 1 36 0 12 2026-09-12T22:48:00.963Z 2026-09-12T22:48:00.963Z +``` + +**That is eight rows from smoke tests, not a workload.** The query shape is +proven; the volume proves nothing. Mapped onto the formula: +`T_in = tokens_input + tokens_cache_creation`, `T_tot = T_in + tokens_cache_read`, +`T_out = tokens_output`, and `R_h` comes from grouping by +`strftime('%Y-%m-%dT%H', timestamp)` together with `connection_id`. + +Two limits on this source. It only sees traffic that went **through** the +gateway — a developer pointing Claude Code straight at their own subscription is +invisible to it. And `usage_history` competes with `call_logs` for the same +facts; `call_logs` carries the per-request row with `connection_id`, `status` +and the same token columns, which is the better source when you need to separate +successes from 429s. + +--- + +## The limit that is not capacity + +Everything above sizes **technical capacity**. None of it authorises sharing a +subscription, and the vendor text is explicit +`[FONTE: https://code.claude.com/docs/en/legal-and-compliance — lido em 2026-09-12]`: + +> "Advertised usage limits for Pro and Max plans assume **ordinary, individual +> usage** of Claude Code and the Agent SDK." + +> "**OAuth authentication is intended exclusively for purchasers** of Claude +> Free, Pro, Max, Team, and Enterprise subscription plans and is designed to +> support ordinary use of Claude Code and other native Anthropic applications." + +> "Anthropic does not permit third-party developers to offer Claude.ai login +> into their own applications, or to **route requests through Free, Pro, or Max +> plan credentials on behalf of their users**." + +> "**Customers may not pay for, resell, or intermediate Claude usage on their +> end users' behalf.** Each end user must authenticate with their own Anthropic +> API key, Claude subscription plan credentials, or 3P inference provider +> credential." + +That closes the original question with an answer that **does not depend on +measuring anything**: + +> **For Claude Pro/Max, `L = N`.** Twelve developers, twelve subscriptions, each +> bought and authenticated by its own holder. The gateway does not reduce that +> number. It exists for routing, cross-family fallback, credential renewal and +> observability over accounts that are **already individual** — and that is +> exactly where it pays for itself. + +The API-key sizing applies in full to the **API path** — an organisation key, +billed to the organisation, distributed internally. The same document allows it +in as many words: *"This does not restrict how customers provision and manage +their own API keys … for use by the customer's own authorized users."* + +For **Google AI Pro / Antigravity** and for **OpenAI** plans, the equivalent +terms were not read. `[A VERIFICAR: leia os termos de assinatura de cada um e +cite URL + data, como foi feito acima com a Anthropic]`. Do not assume symmetry +between vendors. + +One operational consequence, since it is the natural shape of any gateway with +every account registered: several sessions on one account draw no attention, but +several accounts leaving through one address do. OmniRoute models the fix as a +per-account egress binding, and a pool that is inactive or missing its address +fails silently back to the host's — the whole of +[Egress and Multi-Session](Egress-And-Multi-Session) is about that, and +[Egress Testing](Egress-Testing) is the bench that proves where traffic really +leaves from. + +--- + +## Credential renewal is not quota + +Two different clocks. Confusing them produces the wrong diagnosis, and this +repository exists partly because they were confused. + +| | Credential validity | Quota | +| :--- | :--- | :--- | +| Duration | 3,599 s ≈ 1 h, read off the connection | 5 h / weekly; 2 h per family, observed | +| Symptom | 401, "spontaneous" disconnection | 429 / 503 | +| Field | `expires_at` | `rate_limited_until` | +| Who fixes it | automatic renewal — what this synchronizer does | wait for the window, or buy a seat | +| Scales with the team? | **No** | **Yes** | + +The hour in the first column is not folklore: the gateway stores the lifetime the +provider handed it, and on the OAuth connection here it reads +`expires_in = 3599` — the query is in the quota-subsystem section, same output. +`[FONTE: `SELECT provider, auth_type, expires_in FROM provider_connections WHERE auth_type='oauth'`, 2026-09-13T02:20:53Z]` +That is **one** connection on **one** provider, so read it as "this credential +lasts an hour", not as "OAuth tokens last an hour". + +The "it logs itself out after an hour" complaint is a **storage format** problem, +not a shortage of quota: `expires_at` is a TEXT column that OmniRoute reads with +`new Date(...)`, so a numeric epoch written as text becomes an invalid date, the +gateway concludes the connection has no known expiry, and proactive renewal +stops for that connection. The synchronizer writes ISO-8601 there, the gateway's +own native format (`src/omini_rtksync/gateway.py:209-243`). **Neither clock +belongs in the capacity formula.** [Upstream Fixes](Upstream-Fixes) has the +gateway-side story of that parsing bug. + +--- + +## Reproducing everything on this page + +Both scripts are versioned here, because a number without a reproducible script +becomes, given enough time, an invented number. Check the claim before you trust +the page: `git ls-files tools/` has to list both of them. + +| Script | What it produces | +| :--- | :--- | +| `tools/measure_agent_usage.py` | The measured demand profile. Reads **only** the numeric `usage` fields, `message.id` and `timestamp` from Claude Code history — no conversation content is read, aggregated or printed. Deduplicates by `message.id`, and prints the inflation factor that deduplication removes. `--until ISO` freezes the corpus at a past instant, which is what makes a published number checkable. | +| `tools/sizing.py` | The two sizing tables and the burst slack. Its first four lines of output label every input as `medido`, `publicado` or `arbitrado`, with the exact measurement command — the chain from history to table is meant to be walked backwards. Replace the constants with your own and recalculate. | + +The numbers on this page come from three different places and the difference is +the whole point: + +| Class | Where it comes from | What it is worth | +| :--- | :--- | :--- | +| Published | a vendor page, with URL and date | quote it, do not average it | +| Measured | a script in this repository, with its command | reproducible — on **that** corpus, at **that** cut | +| Arbitrated | an operations choice (`c`, slack) | a worked example; measure your own | + +Anything that fits none of the three does not belong on the page. + +To find `C_window` for a subscription, the only place it surfaces is +**Settings → Usage**, which shows the progress bars for the five-hour and weekly +windows `[FONTE: https://support.claude.com/en/articles/11049741-what-is-the-max-plan — lido em 2026-09-12]`: + +1. Wait for the window to reset and note the time. +2. Work a typical stretch inside the window. +3. Read the consumed fraction `p` from the bar, and run the script restricted to + that period, summing **total** tokens moved — call it `D_measured`. On an + OAuth connection registered in OmniRoute, `quota_snapshots.remaining_percentage` + gives you `1 − p` per window key without the eyeballing — see the section + above for what it does and does not tell you. +4. `C_window ≈ D_measured / p`, in total tokens. + +It is a measurement with a stated procedure, not a guess. And it holds for *that* +plan, *that* model and *that* effort level — the vendor's own page says all four +factors move the result. + +Finally, when inspecting a stack's configuration to confirm what was injected, +always run `docker compose --no-interpolate config`. Without the flag the command +prints the secrets from the environment straight to your terminal. + +--- + +# Em português + +A pergunta chega sempre como uma conta de dividir: *somos doze, quantos planos +Max?* O problema é que **o divisor não é publicado por ninguém**. Nenhuma das +assinaturas de consumo envolvidas declara a capacidade absoluta de um assento — +o que existe é multiplicador relativo e janela de reset. Uma tabela dizendo "1 +licença atende 4 devs" só poderia sair de um número inventado, e o número +inventado seria repetido por anos por quem não tem como conferir. + +Então esta página entrega três coisas: a **fórmula**, a **demanda** medida de +verdade, e o **comando** que acha o divisor que falta no seu ambiente. + +## O que os fornecedores publicam + +Nada de absoluto, com uma exceção. A Anthropic publica janela de 5 h e +multiplicador (`Max 5x`, `Max 20x`) mais um limite semanal; a página de limites +diz que tamanho da conversa, recursos usados, modelo e nível de esforço afetam o +consumo. O Codex publica janela de cinco horas rolante e faixas por plano, e a +própria página avisa que não são limites fixos. O Gemini API remete ao painel. + +A exceção é o **Gemini Code Assist**: Standard **1.500** requisições **por +usuário por dia**, Enterprise **2.000**, e **2** requisições por segundo **por +usuário**. Quando o fornecedor finalmente imprime um número, ele vem carimbado +*por usuário* — não existe um pote de 1.500 que doze devs dividem, existem doze +potes de 1.500. Essa é a forma da resposta inteira. + +Fontes, todas lidas em 2026-09-12: a página do plano Max e a de limites da +Anthropic, a de preços do Codex, a de rate limits do Gemini API e a de quotas do +Gemini Code Assist, listadas com URL na seção em inglês. + +## Onde o número existe: a API + +A tabela de tiers da API da Anthropic é publicada `[FONTE: +https://platform.claude.com/docs/en/api/rate-limits — lido em 2026-09-12]`: +Start 1.000 RPM / 2 M entrada por minuto / 400 mil saída por minuto / US$ 500 de +teto mensal; Build 5.000 / 5 M / 1 M / US$ 1.000; Scale 10.000 / 10 M / 2 M / +US$ 200.000. A mesma página avisa que organizações novas podem começar num tier +de avaliação **abaixo** desses limites, e que um salto brusco de uso dispara +*acceleration limits* — doze devs entrando no mesmo dia acionam os dois. + +E o detalhe que muda a conta: **só a entrada não-cacheada conta para o limite de +tokens por minuto da API**. No histórico medido, **98,3% de todos os tokens +trafegados são leitura de cache**, e a razão entre a mediana da entrada total e a +da entrada que conta é **21,1×**. Quem dimensiona o caminho de API somando cache +lido compra vinte vezes mais do que precisa. **Cuidado com o escopo**: essa +regra é da página da API. Nada do que foi lido diz que o medidor de 5 h de uma +assinatura ignora leitura de cache — por isso a tabela de assinatura usa **token +total**. + +## A fórmula + +`U_sim = N × c`, e a demanda dentro da janela é +`D = U_sim × R_h × tokens_por_requisição × W_h × (1 + F)` — com entrada +não-cacheada no caminho de API e token total no caminho de assinatura. +`L = max(ceil(D / C_janela), L_rajada)`, com a rajada conferida à parte, porque +quota por janela e limite por minuto são tetos diferentes e o segundo estoura +primeiro. **A unidade de `D` e a de `C_janela` têm de ser a mesma** — misturar as +duas é o erro silencioso mais provável aqui. + +## Demanda medida + +**Isto é um instantâneo, não uma constante.** O histórico do Claude Code é um +corpus vivo: cresce a cada sessão, e o mesmo script amanhã na mesma máquina dá +outro número sem que nenhum dos dois esteja errado. O que torna um número +publicado conferível é o corte — os arquivos de sessão são append-only, então +tudo que está antes de um instante passado parou de se mexer. Daí o `--until`: + +```bash +./.venv/bin/python tools/measure_agent_usage.py --until 2026-09-13T00:00:00Z +``` + +`[FONTE: saída do comando acima, histórico de UMA máquina, corte em 2026-09-13T00:00:00Z]` +— 114 sessões, 6.648 turnos únicos: `T_in` mediana 4.178 e p90 8.227; `T_out` +mediana 706 e p90 1.361; `T_tot` mediana 88.353 e p90 303.214; `R_h` mediana 194 +req/h e p90 343; pico de 13 sessões simultâneas. A saída completa, com os nomes +de campo do script, está na seção em inglês. Sem `--until` o script lê o +histórico de hoje — que é o que você quer ao dimensionar o seu próprio time. + +Quatro ressalvas viajam junto, sempre: + +1. **A deduplicação não é opcional.** Uma resposta da API é gravada em várias + linhas `type: "assistant"`, repetindo o mesmo `message.id` e o mesmo `usage`. + Contar linhas infla tudo em **2,16×** — 34.305 linhas de resposta contra + 15.905 ids distintos. Isso deixou de ser afirmação em prosa: o próprio script + conta e imprime o fator nas últimas três linhas da saída. +2. **21,1× é razão entre duas medianas**, não a mediana das razões — serve para + ordem de grandeza, não para contabilidade. +3. **A máquina medida roda orquestração com subagentes.** O pico de 13 sessões é + paralelismo de um operador só. Trate `R_h ≈ 194 req/h` como **sessão de + agente**, uma requisição a cada ~19 s, não como "um dev digitando". Rode o + script no seu ambiente. +4. **É uma máquina, um operador, um jeito de trabalhar.** O corte torna o número + *auditável*, não *geral*. As tabelas abaixo são exemplo resolvido do método, + não tabela de consulta. + +## Dimensionamento + +`[FONTE: `./.venv/bin/python tools/sizing.py`, com as entradas medidas acima]` — +`W_h = 5 h` é publicado. Os outros dois parâmetros, não: + +> **`c = 0,6` e folga de 30% são arbitrados.** Ninguém mediu isso aqui. `c` é a +> fração do time pedindo no mesmo instante, e a tabela da fórmula acima já diz +> que ele tem de ser medido — no seu time, contando sessões simultâneas, não +> lendo esta página. O `tools/sizing.py` imprime esse valor sob o rótulo +> `arbitrado` nas primeiras linhas da saída justamente para que ele nunca seja +> citado como achado. É o parâmetro que mais move o resultado: medir +> concorrência vale mais do que qualquer tabela desta página. + +**Caminho de assinatura**, em token total, que é para o que o OmniRoute existe: + +| Perfil | Devs | `U_sim` | Demanda numa janela de 5 h | Licenças | +| :--- | ---: | ---: | ---: | :--- | +| mediana | 3 | 1,8 | 202.146.118 tokens | `ceil(D / C_janela)` | +| mediana | 12 | 7,2 | 808.584.473 tokens | `ceil(D / C_janela)` | +| mediana | 40 | 24,0 | 2.695.281.576 tokens | `ceil(D / C_janela)` | +| p90 | 3 | 1,8 | 1.222.289.932 tokens | `ceil(D / C_janela)` | +| p90 | 12 | 7,2 | 4.889.159.730 tokens | `ceil(D / C_janela)` | +| p90 | 40 | 24,0 | 16.297.199.100 tokens | `ceil(D / C_janela)` | + +A coluna da direita fica simbólica **porque nenhum fornecedor publica +`C_janela`**. É exatamente aí que método se diferencia de tabela inventada: o +método diz o que falta, em que unidade e como obter. + +**Caminho de chave de API** — quatro das cinco conexões da instalação +inspecionada são chaves, não contas OAuth (o comando que mostra isso e a saída +dele estão na seção do subsistema de quota, mais abaixo). Aqui o teto é publicado +e a conta fecha, em entrada não-cacheada: + +| Perfil | Devs | `c` | `U_sim` | RPM | Entrada/min | Saída/min | Tier mínimo | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | :--- | +| mediana | 3 | 0,6 | 1,8 | 8 | 31.611 | 5.342 | Start | +| mediana | 12 | 0,6 | 7,2 | 30 | 126.443 | 21.366 | Start | +| mediana | 12 | 1,0 | 12,0 | 50 | 210.738 | 35.611 | Start | +| mediana | 40 | 0,4 | 16,0 | 67 | 280.984 | 47.481 | Start | +| mediana | 40 | 0,6 | 24,0 | 101 | 421.477 | 71.221 | Start | +| mediana | 40 | 1,0 | 40,0 | 168 | 702.461 | 118.702 | Start | +| p90 | 3 | 0,6 | 1,8 | 13 | 110.053 | 18.206 | Start | +| p90 | 12 | 0,6 | 7,2 | 54 | 440.210 | 72.824 | Start | +| p90 | 40 | 0,6 | 24,0 | 178 | 1.467.368 | 242.748 | Start | +| p90 | 40 | 1,0 | 40,0 | 297 | 2.445.613 | 404.580 | **Build** | + +Um time de quarenta no perfil mediano **ainda cabe no tier Start**. Só o canto +extremo pede Build, e quem empurra é entrada por minuto (2,45 M contra 2,00 M), +não RPM. O teto de rajada raramente dói: contra os 1.000 RPM do Start sobram +132× para 3 devs, 33× para 12 e 9× para 40. E **`c` move mais o resultado que +`N`** — 40 devs a `c=0,4` e 12 a `c=1,0` caem no mesmo tier; medir concorrência +vale mais que contar cadeiras. + +## O que este sincronizador te mostra sobre isso + +**O sinal de saturação.** O OmniRoute grava a trava na coluna +`rate_limited_until` de `provider_connections`, ou como `rateLimitedUntil` dentro +da coluna JSON `data` em instalações migradas. O `get_all_connections` projeta as +duas numa chave só, `rateLimitedUntil` (`src/omini_rtksync/gateway.py:193`), e +`ConnectionRecord.rate_limit_active` a lê como **prazo, não bandeira** +(`src/omini_rtksync/models.py:157-169`): ela guarda o instante em que a janela do +provedor reabre, e tratar a presença do campo como "limitada" deixava a conexão +amarela para sempre depois do primeiro 429. Com o prazo no futuro, o +`health_status` vira `rate_limited` e a tela pinta o badge **Rate limit** na +coluna de saúde da tabela de conexões (`src/omini_rtksync/i18n.py:206`, +`src/omini_rtksync/render.py:36`). Onde essa coluna fica: [Dashboard](Dashboard). + +**O contador que fecha o laço.** Vencido o prazo, o sincronizador limpa a trava e +registra `Trava de rate limit vencida removida` +(`src/omini_rtksync/cli.py:143-149`). Essas entradas se acumulam no modal de +**Logs** do agendador. Contá-las por conta por dia durante uma semana é a única +evidência que de fato fecha a conta: conta que trava todo dia está +subdimensionada, conta que nunca trava é folga que absorve mais gente. Todo o +resto desta página é projeção. + +**O headcount.** Os cartões de métrica trazem **Contas OAuth** e **Chaves de +API** (`src/omini_rtksync/i18n.py:149-150`). O primeiro é o `L` da seção de +termos de uso; o segundo é o caminho dimensionado por tier. + +**A alavanca mais barata.** **Combos registrados** e a seção **Combos de +resiliência** listam cada combo e sua cascata de modelos, lidos da tabela +`combos` do OmniRoute como `name`, `kind` e `models` +(`src/omini_rtksync/gateway.py:449-473`). A quota pode se esgotar **por família +de modelo** com a conta seguindo saudável: durante um bloqueio do Antigravity, +todos os modelos da família Gemini devolveram 503 enquanto os Claude e os de +peso aberto da mesma conta continuaram respondendo `[FONTE: +pathbit-ai-for-devs/0002_claude_gravity_utilizando_9router/article/ARTICLE.md:684-720]`. +Um combo de fallback que atravessa famílias multiplica a capacidade efetiva +**sem comprar licença**, e é a única alavanca aqui que não esbarra no limite de +termos de uso. A instalação inspecionada tinha **0 combos** registrados — o +comando de contagem e a saída completa estão na seção do subsistema de quota. + +**Automação.** O `/api/status` devolve `connectionsCount` e `combosCount` +(`src/omini_rtksync/web.py:401-412`), e `ominirtksync --status --db-path +~/.omniroute/data/storage.sqlite` imprime o mesmo inventário. O produto tenta +cinco caminhos, nesta ordem, e fica com o primeiro que existir: +`/app/data/storage.sqlite`, `/app/data/data.sqlite`, +`~/.omniroute/data/storage.sqlite`, `~/.omniroute/storage.sqlite`, +`~/.omniroute/data.sqlite` +`[FONTE: src/omini_rtksync/config.py:221-227]`. Ou seja, o `data/` do meio não é +exigido — ele só vem antes. Escreva o caminho que a sua instalação realmente +tem: num contêiner é o primeiro, numa instalação local costuma ser o terceiro. + +**O que o painel não mostra, e não vai fingir que mostra:** contagem de tokens, +percentual de quota consumida, janela restante ou `max_concurrent`. O +sincronizador lê saúde de credencial; não é produto de medição. + +**Uma lacuna, registrada:** `provider_connections` tem também +`backoff_level INTEGER DEFAULT 0` `[FONTE: schema lido de +diegosouzapw/omniroute:latest em execução, 2026-09-12]`. O sincronizador limpa +`rate_limited_until` quando o prazo vence (`src/omini_rtksync/gateway.py:376-378`) +mas **nunca zera `backoff_level`** — a string não aparece em lugar nenhum sob +`src/`. O irmão 9RTKSync zera o equivalente ao limpar a trava `[FONTE: +9RTKSync/src/nine_rtksync/normalizer.py:87 — `data["backoffLevel"] = 0`]`. Se o +OmniRoute decai o nível sozinho é `[A VERIFICAR: leia o tratamento de +backoff_level no fonte do gateway]`. Fica anotado porque um nível de backoff +velho faria uma conta parecer mais saturada do que está. + +## O lugar com o formato de `C_janela` — existe, e está parcialmente preenchido + +O schema do OmniRoute traz um subsistema de quota inteiro — e schema, ao +contrário de contagem de linha, é propriedade durável: vem da imagem, não do +tráfego, e sai de um comando só (`SELECT sql FROM sqlite_master`, na seção em +inglês). O centro dele é +`provider_quota_state(connection_id, model)` com `tokens_used`, `token_limit`, +`window_start` e `window_reset`: é o `C_janela` em forma de banco, e na +granularidade que a observação do Antigravity diz ser a necessária — por família +de modelo, não por conta. Ao lado ficam `quota_snapshots` (`window_key`, +`remaining_percentage`, `is_exhausted`, `next_reset_at`, `window_duration_ms`, +`raw_data`), `provider_plans` (`dimensions_json`, `source` restrito a `auto` ou +`manual`) e `quota_pools` com `quota_pool_connections`. Repare no `window_key`: +ele é a dimensão por modelo do próprio `quota_snapshots`. + +**Contar exige dois comandos, e o segundo é fácil de errar.** O contêiner não tem +`sqlite3`, e o banco roda em modo WAL — um `docker cp` só do `storage.sqlite` lê +arquivo defasado e subconta o que ainda está no log de escrita (medido numa cópia +só, lida duas vezes: 188 `call_logs` sem o `-wal` ao lado, 209 com ele — 21 +linhas, 10% da tabela, invisíveis ao comando mais curto, e sem erro nenhum na +tela). Os comandos com o `-wal` +junto, e a saída completa, estão na seção em inglês. O resultado, no instante +2026-09-13T02:20:53Z: `provider_quota_state` 0, `provider_plans` 0, `quota_pools` +0, `daily_usage_summary` 0, `combos` 0, `usage_history` 8, `provider_connections` +5, `call_logs` 209 — e **`quota_snapshots` com 48 linhas**. + +**Leia isso como instante, não como propriedade.** `call_logs` e +`quota_snapshots` sobem conforme passa tráfego: neste mesmo contêiner, `call_logs` +sobem enquanto você lê. Rode o comando duas vezes, com alguns minutos de +intervalo, e as duas contagens diferem — é essa a demonstração. Leituras +anteriores desta mesma instalação não são reproduzíveis por você e, por isso, +não são citadas. Os zeros são a parte durável; o 48 +é a parte interessante, porque uma leitura anterior desta mesma instalação pegou +`quota_snapshots` em 0 e a página concluiu, errado, que nada nunca preenche +aquilo. O que houve é que o primeiro snapshot foi gravado às 23:29:23.587Z, +*depois* da última linha de `usage_history`, às 22:54Z. A medição estava certa; a +conclusão tirada dela, não. + +Então o achado honesto é o oposto de "vazio": **o OmniRoute preenche +`quota_snapshots` sozinho, por chave de janela, na conexão OAuth — e só nela.** +São 16 `window_key` distintos numa conta só, misturando nomes de modelo +(`claude-sonnet-4-6`, `gemini-3.1-pro-high`, `gpt-oss-120b-medium`) e chaves +agregadas (`claude_gpt_weekly`), mais duas (`chat_20706`, `chat_23310`) que +parecem contadores por conversa e não se encaixam em nenhuma das descrições. As +quatro conexões por chave de API não têm +nenhum. É a dimensão por modelo, populada, sem ninguém ter declarado plano em +`provider_plans` antes. + +**E mesmo assim não é o `C_janela`.** Todas as 48 linhas trazem +`remaining_percentage = 100.0`, `is_exhausted = 0`, com `window_duration_ms` e +`raw_data` NULL — conta que ninguém trabalhou o bastante para arranhar. Mais +importante que o número vazio é a **unidade**: porcentagem é `1 − p`, a fração +consumida. É o lado esquerdo do procedimento de calibração do fim desta página, +não a resposta dele. Para ter `C_janela` em tokens, ainda é preciso casar essa +porcentagem com o que você de fato trafegou na mesma janela. O que o OmniRoute dá +de graça é o `p` — lido por máquina, por modelo, em vez de estimado a olho numa +barra de progresso. É um atalho real, e vale exatamente isso. + +O `provider_quota_state`, que é quem carrega o `token_limit` — o número absoluto +—, continua em 0. Se o OmniRoute chega a escrever ali, e a partir de qual +resposta do provedor, é `[A VERIFICAR: leia no fonte do gateway quem escreve em +provider_quota_state, e se depende de plano declarado em provider_plans]`. Este +sincronizador não lê nenhuma dessas tabelas: `git grep -n +'quota_snapshots\|provider_quota_state' -- src/` não devolve nada. + +Vale a mesma ressalva para `max_concurrent` e `rate_limit_protection`, que são +onde o `c` seria materializado por conta: `max_concurrent` NULL nas cinco +conexões e `rate_limit_protection` em 0 nas cinco, pelo mesmo comando, no mesmo +instante. O botão existe e ninguém girou. + +**Sobre granularidade:** o 9Router guarda travas por família como chaves +`modelLock_*` dentro do JSON da conexão. **No OmniRoute não existe `modelLock_*`.** +Aqui a *trava* é um prazo único para a conexão inteira — mas não conclua daí, +como uma versão anterior desta página concluiu, que falta granularidade por +modelo. Não falta: ela vive em `quota_snapshots.window_key`, que está preenchido, +e viveria em `provider_quota_state.model`, que não está. Duas tabelas, duas +respostas, e só uma delas vazia. Não porte o parágrafo do irmão para cá, e não +porte este de volta. + +## Meça a demanda aqui, não só no laptop do dev + +O script lê o histórico de uma máquina. O OmniRoute tem algo melhor para um +time, porque é por conta: `usage_history` registra cada chamada que passou pelo +gateway com exatamente a separação de tokens que a fórmula pede. + +```bash +# Instalação local; em contêiner, copie antes com o `-wal` junto, como na seção +# do subsistema de quota -- sem ele a contagem sai menor do que é. +sqlite3 -header -column ~/.omniroute/data/storage.sqlite " +SELECT provider, + count(*) AS requisicoes, + sum(tokens_input + tokens_cache_creation) AS entrada_que_conta, + sum(tokens_cache_read) AS leitura_de_cache, + sum(tokens_output) AS saida, + min(timestamp) AS de, + max(timestamp) AS ate +FROM usage_history GROUP BY provider ORDER BY requisicoes DESC;" +``` + +A saída real contra o banco vivo está na seção em inglês: quatro provedores, +oito requisições. **São oito linhas de teste de fumaça, não carga de trabalho** — +a forma da consulta está provada, o volume não prova nada. No mapeamento: +`T_in = tokens_input + tokens_cache_creation`, +`T_tot = T_in + tokens_cache_read`, `T_out = tokens_output`, e `R_h` sai de +agrupar por `strftime('%Y-%m-%dT%H', timestamp)` junto com `connection_id`. + +Dois limites: a tabela só enxerga o que passou **pelo** gateway — um dev +apontando o Claude Code direto para a assinatura dele é invisível — e +`call_logs` é a fonte melhor quando você precisa separar sucesso de 429, porque +guarda a linha por requisição com `status` e `connection_id`. + +## O limite que não é capacidade + +Tudo acima dimensiona **capacidade técnica**. Nada disso autoriza compartilhar +assinatura, e o texto do fornecedor é explícito `[FONTE: +https://code.claude.com/docs/en/legal-and-compliance — lido em 2026-09-12]`: os +limites anunciados de Pro e Max pressupõem *"ordinary, individual usage"*; a +autenticação OAuth é *"intended exclusively for purchasers"* dos planos; a +Anthropic não permite *"route requests through Free, Pro, or Max plan +credentials on behalf of their users"*; e *"Customers may not pay for, resell, or +intermediate Claude usage on their end users' behalf. Each end user must +authenticate with their own Anthropic API key, Claude subscription plan +credentials, or 3P inference provider credential."* + +Isso fecha a pergunta original com uma resposta que **não depende de medir nada**: + +> **Para Claude Pro/Max, `L = N`.** Doze devs, doze assinaturas, cada uma +> comprada e autenticada pelo seu titular. O gateway não reduz esse número. Ele +> serve para roteamento, fallback entre famílias, renovação de credencial e +> observabilidade sobre contas que **já são individuais** — e é exatamente aí que +> paga o próprio custo. + +O dimensionamento por tier se aplica integralmente ao **caminho de API** — chave +da organização, cobrada ao titular, distribuída internamente. O mesmo documento +ressalva isso como permitido: *"This does not restrict how customers provision +and manage their own API keys … for use by the customer's own authorized +users."* + +Para **Google AI Pro / Antigravity** e para os planos da **OpenAI**, os termos +equivalentes não foram lidos: `[A VERIFICAR: leia os termos de assinatura de cada +um e cite URL + data, como foi feito com a Anthropic]`. Não presuma simetria +entre fornecedores. + +Uma consequência operacional, porque é o formato natural de um gateway com todas +as contas cadastradas: várias sessões na mesma conta não incomodam; o que chama +atenção é o inverso, várias contas saindo pelo mesmo endereço. O OmniRoute modela +o remédio como vínculo de saída por conta, e um pool inativo ou sem endereço cai +em silêncio para o endereço do host — é disso que trata +[Egress and Multi-Session](Egress-And-Multi-Session), e +[Egress Testing](Egress-Testing) é a bancada que prova por onde o tráfego sai de +verdade. + +## Renovação de credencial não é quota + +Dois relógios diferentes; confundi-los produz o diagnóstico errado. + +| | Validade da credencial | Quota | +| :--- | :--- | :--- | +| Duração | 3.599 s ≈ 1 h, lido da conexão | 5 h / semanal; 2 h por família, observado | +| Sintoma | 401, desconexão "espontânea" | 429 / 503 | +| Campo | `expires_at` | `rate_limited_until` | +| Quem resolve | renovação automática — o que este sincronizador faz | esperar a janela, ou mais uma assinatura | +| Escala com o time? | **Não** | **Sim** | + +A hora da primeira coluna não é folclore: o gateway guarda a validade que o +provedor entregou, e na conexão OAuth daqui ela vem como `expires_in = 3599` +`[FONTE: `SELECT provider, auth_type, expires_in FROM provider_connections WHERE auth_type='oauth'`, 2026-09-13T02:20:53Z]`. +É **uma** conexão de **um** provedor: leia como "esta credencial dura uma hora", +não como "token OAuth dura uma hora". + +O "desconecta sozinho depois de uma hora" é **formato de gravação**, não falta de +cota: `expires_at` é coluna TEXT lida com `new Date(...)`, e um epoch numérico +gravado como texto vira data inválida, o gateway conclui que a conexão não tem +expiração conhecida e para de renovar preventivamente. O sincronizador grava +ISO-8601, o formato nativo do próprio gateway +(`src/omini_rtksync/gateway.py:209-243`). **Nenhum dos dois relógios entra na +fórmula.** O [Upstream Fixes](Upstream-Fixes) conta o lado do gateway nesse bug +de parsing. + +## Para reproduzir + +Os dois scripts estão versionados aqui, porque número sem script que o reproduza +vira, com o tempo, número inventado. Confira antes de confiar na página: o +`git ls-files tools/` tem de listar os dois. + +`tools/measure_agent_usage.py` produz o perfil de demanda — lê **apenas** os +campos numéricos de `usage`, o `message.id` e o `timestamp`, sem tocar em +conteúdo de conversa, deduplica por `message.id` e imprime o fator de inflação +que a deduplicação remove. O `--until ISO` congela o corpus num instante passado, +e é isso que torna um número publicado conferível. `tools/sizing.py` resolve as +tabelas acima e rotula cada entrada como `medido`, `publicado` ou `arbitrado` nas +primeiras linhas da saída, com o comando exato da medição: a cadeia da medição +até a tabela é para ser percorrida de trás para frente. Troque as constantes +pelas suas e recalcule. + +Os números desta página vêm de três lugares diferentes, e a diferença é o ponto +inteiro: **publicado** (página do fornecedor, com URL e data — cite, não faça +média), **medido** (script deste repositório, com o comando — reproduzível +*naquele* corpus, *naquele* corte) e **arbitrado** (escolha de operação, como `c` +e a folga — exemplo resolvido, meça o seu). O que não couber em nenhum dos três +não entra na página. + +Para achar `C_janela` de uma assinatura, o único lugar onde ela aparece é +**Settings → Usage**, com as barras da janela de 5 h e da semanal: espere o +reset, trabalhe uma jornada típica dentro da janela, leia a fração `p` consumida +na barra, rode o script restrito ao período somando o token **total** trafegado +(`D_medido`) e faça `C_janela ≈ D_medido / p`. É medição com procedimento +declarado, não palpite — e vale para *aquele* plano, *aquele* modelo e *aquele* +nível de esforço. + +Por fim, ao conferir o que foi injetado numa stack, rode sempre +`docker compose --no-interpolate config`. Sem a flag o comando despeja os +segredos do ambiente direto no seu terminal. diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index 6c20cbe..937158d 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -74,10 +74,9 @@ do not control, and you accept that the address is public to whoever has it. internet and your accounts is `REQUIRE_LOGIN` and `REQUIRE_API_KEY`. - The address changes every time the tunnel is re-enabled, unless you bring your own named Cloudflare tunnel. -- The dashboard blocks the button while login is off — but that gate lives in the - screen. The gateway's own `POST /api/tunnel/enable` does not re-check it, so a script or an - extension can enable the tunnel while login is still off. Set the two flags - first and the question does not arise. +- Running `cloudflared` yourself means **no screen gates the tunnel**: there is no + dashboard check to warn you that login is still off. The command publishes the + gateway exactly as it is. Set the two flags first and the question does not arise. --- @@ -158,6 +157,120 @@ and reach it through the same tunnel or tailnet you use for everything else. --- +--- + +## Colocando de pé: os dois perfis do compose + +O botão `Tunnel` / `Tailscale` que o painel do gateway oferece instala o binário +**dentro do contêiner do gateway**, que é efêmero: recriar a stack desfaz a +instalação, e nada no compose registra que aquilo existiu. É por isso que o +diálogo do Tailscale responde *"Tailscale is not installed"* numa máquina onde +você jurava ter instalado. + +Este repositório declara os dois como serviços opcionais, que sobem e descem com +o resto e deixam rastro em arquivo: + +```bash +# URL pública, via Cloudflare +docker compose -f docker-compose.example.yml --profile tunel up -d + +# só quem está na sua tailnet +docker compose -f docker-compose.example.yml --profile tailnet up -d +``` + +Sem `--profile`, nenhum dos dois sobe — o padrão continua sendo o painel preso +ao loopback. + +### Antes de ligar qualquer um dos dois + +O painel do gateway avisa em vermelho: *"Change the default dashboard password +before activating the tunnel."* O aviso não é decoração, e a ordem importa: + +``` +1. troque a senha do gateway (INITIAL_PASSWORD no .env, e o painel dele) +2. REQUIRE_LOGIN=true +3. REQUIRE_API_KEY=true + uma chave para as suas ferramentas +4. só então o túnel ou a tailnet +``` + +Com a porta em `127.0.0.1`, `REQUIRE_LOGIN=false` é aceitável porque só a sua +máquina alcança. No instante em que um túnel sobe, esse raciocínio se inverte: +`/v1` é prefixo público por projeto, então **quem souber a URL gasta as suas +contas**. + +### Cloudflare: quick tunnel ou túnel nomeado + +Sem `TUNNEL_TOKEN` no `.env`, o serviço sobe um **quick tunnel**: zero +configuração, e o endereço sai no log. + +```bash +docker logs 9rtk-tunel 2>&1 | grep -o 'https://[a-z0-9-]*\.trycloudflare\.com' +``` + +Esse endereço é **novo a cada subida** e é **público para quem o tiver** — não há +lista de permissão. Serve para uma demonstração, não para o dia a dia. + +Com `TUNNEL_TOKEN` preenchido (um túnel nomeado, criado no painel da Cloudflare), +o endereço passa a ser estável e você ganha o que interessa de verdade: +**Cloudflare Access na frente**, que autentica antes de a requisição chegar no +gateway. É a única das opções aqui em que a autenticação acontece fora do +produto. + +```bash +# .env +TUNNEL_TOKEN= +``` + +### Tailscale: a opção que não publica nada + +A diferença que decide a escolha: o túnel dá um endereço que **qualquer um** +alcança; a tailnet só admite dispositivo que você cadastrou. Para um painel que +lê credenciais, a segunda é quase sempre a certa. + +```bash +# 1. gere uma chave efêmera em +# https://login.tailscale.com/admin/settings/keys +# 2. ponha no .env +TS_AUTHKEY=tskey-auth-... + +# 3. suba o perfil +docker compose -f docker-compose.example.yml --profile tailnet up -d + +# 4. descubra o nome na tailnet +docker exec ominirtk-tailnet tailscale status +``` + +Chave **efêmera** de propósito: o nó some sozinho da sua tailnet quando o +contêiner morre, em vez de acumular máquinas fantasma na lista. + +### Qual dos dois + +| | Cloudflare quick | Cloudflare nomeado | Tailscale | +| :--- | :--- | :--- | :--- | +| Quem alcança | qualquer um com a URL | quem o Access deixar | só a sua tailnet | +| Endereço estável | não | sim | sim | +| Precisa de conta | não | sim (grátis) | sim (grátis) | +| Autenticação fora do produto | não | **sim** (Access) | não (mas a rede já filtra) | +| Bom para | uma demonstração | equipe, uso diário | você e os seus aparelhos | + +### O que o sincronizador faz por você aqui + +O painel deste sincronizador **não** deve ser exposto: ele lê o banco do gateway +e mostra a saúde das credenciais. Os dois perfis acima apontam para o **gateway**, +não para ele. Alcance o painel pelo mesmo túnel ou tailnet que você já usa para +o resto, ou por `127.0.0.1` mesmo. + +Se você expuser assim mesmo, o login já está preparado: formulário próprio com +cookie de sessão, teto de dez tentativas por endereço a cada cinco minutos +(**429** com `Retry-After`), espera que dobra a cada falha e prova de trabalho +depois da terceira. Confira o que você expôs: + +```bash +# de outro aparelho, SEM credencial — os dois têm de recusar +curl -si https:///v1/models | head -1 # espera-se 401 +curl -si https:/// | head -1 # espera-se 401 ou o formulário +``` + # Em português Alcançar o gateway de outra máquina tem três respostas usuais. Elas diferem em @@ -201,11 +314,18 @@ A ordem, portanto, não é preferência: 3. só então, o túnel ou o Tailscale ``` -## Opção 1 — túnel Cloudflare (nativo do 9Router) +## Opção 1 — túnel Cloudflare (sem botão nativo aqui) + +O OmniRoute não tem botão de túnel próprio — isso é recurso do 9Router. Para +obter o mesmo resultado, rode o `cloudflared` você mesmo contra a porta +publicada: + +```bash +cloudflared tunnel --url http://127.0.0.1:8082 +``` -A tela **API Endpoint** tem o botão `Tunnel`. Ele registra um quick tunnel da -Cloudflare e devolve um endereço público `https://…trycloudflare.com` que -alcança o gateway sem abrir porta nenhuma no seu roteador. +Ele imprime um endereço público `https://…trycloudflare.com` que alcança o seu +gateway sem abrir porta nenhuma no seu roteador. **Quando serve:** você precisa de uma URL alcançável de qualquer lugar, inclusive de dispositivos que você não controla, e aceita que o endereço seja @@ -213,16 +333,16 @@ público para quem o tiver. **O que saber:** a URL é pública e não há lista de permissão — entre a internet e as suas contas existem apenas `REQUIRE_LOGIN` e `REQUIRE_API_KEY`. O endereço -muda a cada reativação, a menos que você use um túnel nomeado seu. E o bloqueio -do botão enquanto o login está desligado vive **na tela**: o `POST -/api/tunnel/enable` do gateway não reavalia a condição. Ligue as duas variáveis antes e a +muda a cada reativação, a menos que você use um túnel nomeado seu. E rodar o +`cloudflared` à mão significa que **nenhuma tela segura o túnel**: não há +verificação de painel para avisar que o login continua desligado — o comando +publica o gateway exatamente como ele está. Ligue as duas variáveis antes e a questão não se coloca. -## Opção 2 — Tailscale (nativo do 9Router, e o que preferir) +## Opção 2 — Tailscale (o que preferir) -A mesma tela tem o botão `Tailscale`, que instala e conecta o daemon. A sua -máquina entra na sua tailnet e o gateway passa a ser alcançável num endereço -`100.x.y.z`, ou num nome MagicDNS como `http://seu-host:20128`. +A sua máquina entra na sua tailnet e o gateway passa a ser alcançável num +endereço `100.x.y.z`, ou num nome MagicDNS como `http://seu-host:8082`. **Quando serve:** quase sempre. Só os dispositivos que você cadastrou alcançam o gateway — o endereço não é público e não há o que um estranho descubra. diff --git a/docs/wiki/Single-Sign-On.md b/docs/wiki/Single-Sign-On.md new file mode 100644 index 0000000..979160d --- /dev/null +++ b/docs/wiki/Single-Sign-On.md @@ -0,0 +1,424 @@ +# Single sign-on: signing in through an identity provider + +*(Versão em português ao final.)* + +The panel can accept a second way in: an identity provider you already run — Google Workspace, +Microsoft Entra ID, Okta, Keycloak — through **OpenID Connect**. It is optional, off by default, +and it never replaces the local user and password form. + +> **The local form never leaves the screen.** If the identity provider is unreachable, the panel +> still opens with the password you set, and the break-glass recovery credential still works. A +> panel whose only door is somebody else's server is a panel you lose when that server has a bad +> morning. + +--- + +## What this page covers + +| Section | What you get | +|---|---| +| [Before you start](#before-you-start) | the one prerequisite that breaks everything if you skip it | +| [Turning it on](#turning-it-on) | the screen, field by field | +| [Google Workspace](#setting-it-up-in-google-workspace) | a real provider, start to finish | +| [Entra ID and Okta](#entra-id-and-okta) | what changes for the other two | +| [When the provider goes down](#when-the-provider-goes-down) | four ways back in, in order of effort | +| [What is checked on the way back](#what-is-checked-on-the-way-back) | the list, and why each item is on it | +| [SAML 2.0](#saml-20-is-not-in-this-version) | why the tab is greyed out | + +--- + +## Before you start + +**The panel needs a stable public address.** Federated sign-in works by sending the browser to the +provider and having it come back to an address you registered there in advance. If that address +changes, every sign-in attempt ends with the provider refusing to come back. + +This rules out the Cloudflare quick tunnel, which gets a brand new hostname on every start — see +[Remote Access](Remote-Access). Use one of: + +- a **named** Cloudflare tunnel, with a hostname you own; +- **Tailscale**, using the stable machine name on your tailnet; +- any reverse proxy in front of the panel with a fixed name. + +Whatever you pick becomes the **public panel address** in the form, and the panel builds the return +address from it — never from the `Host` header the browser sends, because that header is chosen by +whoever is making the request. + +--- + +## Turning it on + +Sign in with your local password, then press **Settings** in the header bar. The window has two +tabs; the OpenID Connect one is the live one. + +| Field | What goes in it | +|---|---| +| Public panel address | `https://panel.example.com` — exact, no trailing slash | +| Return address | shown, not typed. Copy it to the provider | +| Issuer | `https://accounts.google.com`, or whatever your provider publishes | +| Client ID | from the application you register at the provider | +| Client secret | from the same place. Write-only: see below | +| Scopes | leave `openid email profile` unless you know why not | +| Allowed domains | `example.com,branch.example.com` | +| Allowed e-mail addresses | `boss@example.com` | +| Active provider | switch to OpenID Connect last, once the rest is filled in | +| Your current panel password | required — see below | + +Three of these behave in ways worth knowing about. + +**The client secret never comes back.** The field shows dots when a secret is stored and stays +blank otherwise. Saving with it blank keeps the one already there; you only ever write, never read. +It lives in a file with mode `0600` next to the recovery credential, not in the preferences +database. You can also set it in the environment as `OIDC_CLIENT_SECRET`, and then the environment +wins and the field is locked — the same rule `DASHBOARD_PASSWORD` already follows. + +**The allowed list is mandatory and cannot be empty.** "Sign in with Google" without a filter means +every Google account on the planet gets into your panel. The panel refuses to switch SSO on with +both lists blank, and refuses to complete a sign-in if it somehow finds them blank later. + +**Your current panel password is asked for again.** You are already signed in, so this looks like +friction for nothing. It is not: a stolen eight-hour session would otherwise be enough to point the +panel at a hostile provider and add the attacker to the allowed list — permanent access that +survives you changing your password. + +--- + +## Setting it up in Google Workspace + +1. Open the Google Cloud console and pick (or create) a project. +2. **APIs & Services → OAuth consent screen.** Choose **Internal** if the panel is only for people + in your Workspace; that alone stops outside accounts from ever reaching the consent screen. + Fill in the app name and the support e-mail. +3. **APIs & Services → Credentials → Create credentials → OAuth client ID.** + Application type: **Web application**. +4. Under **Authorised redirect URIs**, paste the *Return address* the panel showed you. It ends in + `/sso/oidc/callback`. It must match character for character, including `https` and the port if + there is one. +5. Save. Google shows the client ID and client secret once — copy both into the panel now. +6. In the panel: issuer `https://accounts.google.com`, allowed domains = your Workspace domain, + active provider = OpenID Connect, type your panel password, save. +7. Sign out and look at the sign-in screen. Below the password form there is now a + **Sign in with accounts.google.com** button. + +If the button is missing, the panel could not reach the provider's discovery document. That is a +network problem, not a configuration one — the panel deliberately hides the button instead of +offering a door that does not open. + +## Entra ID and Okta + +The shape is the same; only the names move. + +**Microsoft Entra ID** — register an application under *App registrations*, add the return address +as a **Web** redirect URI, create a secret under *Certificates & secrets*, and use the issuer from +the *OpenID Connect metadata document* link (it looks like +`https://login.microsoftonline.com//v2.0`). Paste it without the +`/.well-known/openid-configuration` part. + +**Okta** — create an *OIDC / Web Application*, set the sign-in redirect URI to the return address, +and use your org URL as the issuer (`https://your-org.okta.com`, or the custom authorisation server +URL if you use one). + +For both, the issuer you type must be **exactly** the value the provider reports as `issuer` in its +own metadata. The panel compares the two and refuses to go on if they differ — that check is what +stops a response from one provider being accepted as if it came from another. + +--- + +## When the provider goes down + +In order of how little you have to do: + +1. **Just sign in with your password.** The form is right there. This is the answer almost every + time, and it is why the form never goes away. +2. **The recovery credential still works.** `admin` plus the recovery hash gets in whatever the + state of the provider, exactly as described in [Authentication](Authentication). +3. **Scripts and monitoring never noticed.** Anything that does not ask for HTML — `curl`, cron, + your uptime check — authenticates the way it always did. Federated sign-in only ever applies to + the browser door. +4. **Switch it off without opening the panel.** Set `SSO_DISABLED=1` in the environment and bring + the service back up. It beats whatever is in the database, touches nothing, and is reversible by + removing the variable. Use it when the provider is not merely slow but wrong — sending people to + the wrong place, or refusing a domain it used to accept. + +To turn it off from the screen instead: Settings → Active provider → *Off (password only)* → type +your panel password → save. + +> **One thing that is not a bug.** *Sign out* clears the panel session only. Your session at the +> identity provider is still open, so the next click on the SSO button may walk straight back in +> without asking for anything. Federated logout is not implemented in this version. + +--- + +## What is checked on the way back + +When the provider sends the browser back, the panel refuses on the first thing that does not add +up, and every refusal produces **the same message on screen**. Telling you whether it was the state +or the allowed list would also tell an attacker how far they got; the real reason goes to the log. + +| Checked | Why it is on the list | +|---|---| +| Rate limit for the address | the two new routes are public, so they get the same ceiling as the form | +| State cookie present and its signature intact | without the signature, whoever writes both sides always matches | +| State in the URL equals state in the cookie | this is what stops login CSRF — being signed into the attacker's account | +| State not used before | apart from the cookie, the server remembers what it consumed: single use for real | +| No error, and a code is present | | +| The code exchanged with proof of key possession | the code travels in the address bar and stays in history; the key never leaves the cookie | +| `iss` equals the configured issuer | one provider at a time, so there is one right answer | +| `aud` contains the client ID | a valid token issued for a *different* service must not work here | +| Not expired, and issued within five minutes | a generous tolerance turns a deadline into decoration | +| Nonce equals the one from the cookie | ties the token to that particular trip | +| The user info and the token describe the same subject | proves the exchange actually happened | +| E-mail confirmed by the provider | otherwise an unverified address is enough to get in | +| E-mail or domain on the allowed list | | + +Only then is a session issued — **the same signed cookie** the password form issues, with the same +eight-hour life and the same properties. There is no second kind of session. + +The landing page is a real page with a redirect in it, and not an HTTP redirect: browsers do not +carry a strict same-site cookie through a redirect chain that started on another site, and you +would land back on the sign-in form holding a perfectly good session. The destination is always the +panel root — no parameter in the URL is ever used as a destination. + +Every one of these refusals has a test that exercises it with bad input, in +`tests/test_sso_oidc.py`. + +--- + +## SAML 2.0 is not in this version + +The tab is there, the fields are there, and they are disabled. This is a technical limit, not a +missing afternoon of work. + +SAML signs over *Exclusive XML Canonicalization 1.0*. The Python standard library canonicalises XML +too — but with C14N 2.0, a different algorithm, which makes every valid signature look invalid. The +standard library also has no RSA verification at all, and its XML parser does not defend against +signature wrapping, the attack specific to this protocol, where a signed assertion is moved +elsewhere in the document and a forged one takes its place. + +Writing that by hand produces validation that appears to work and accepts forged assertions in +silence. So SAML support waits for a library: `python3-saml` is declared in `pyproject.toml` under +the optional `saml` extra, and the image will need `libxml2`, `libxslt` and `xmlsec` before it can +be enabled. Until then the panel refuses to turn it on, and says so. + +Also out of scope for this version, and deliberately: federated logout, and encrypted SAML +assertions. + +--- + +## Where everything is stored + +| What | Where | Why there | +|---|---|---| +| Issuer, client ID, public address, allowed lists | the panel preferences database, keys prefixed `sso.` | none of it is secret; it is the same table as the language setting | +| Client secret | a file with mode `0600` in the data directory | secrets do not belong in a database that the screen reads from | +| Which provider is active | preferences, one at a time | two providers at once is how a response from one gets accepted as the other | +| The trip state | a short-lived signed cookie, plus a consumed-state list in memory | it dies in ten minutes and grants nothing on its own | + +The data directory is the one the rest of the panel already uses — see `DATA_DIR` in +[Configuration](Configuration). + +--- +--- + +# Entrada federada: entrar por um provedor de identidade + +O painel pode aceitar uma segunda porta: um provedor de identidade que você já opera — Google +Workspace, Microsoft Entra ID, Okta, Keycloak — por **OpenID Connect**. É opcional, nasce +desligada, e nunca substitui o formulário local de usuário e senha. + +> **O formulário local nunca sai da tela.** Se o provedor de identidade estiver fora do ar, o painel +> continua abrindo com a senha que você definiu, e a credencial de recuperação continua entrando. +> Um painel cuja única porta é o servidor de outra pessoa é um painel que você perde no dia em que +> aquele servidor amanhece mal. + +--- + +## Antes de começar + +**O painel precisa de um endereço público estável.** A entrada federada funciona mandando o +navegador ao provedor e fazendo-o voltar a um endereço que você registrou lá antes. Se esse +endereço muda, toda tentativa de entrar termina com o provedor recusando a volta. + +Isso descarta o túnel rápido da Cloudflare, que ganha um nome novo a cada subida — veja +[Remote Access](Remote-Access). Use uma destas opções: + +- um túnel Cloudflare **nomeado**, com um nome que é seu; +- **Tailscale**, com o nome estável da máquina na sua tailnet; +- qualquer proxy reverso na frente do painel, com nome fixo. + +O que você escolher vira o **endereço público do painel** no formulário, e é dele que o painel monta +o endereço de retorno — nunca do cabeçalho `Host` que o navegador manda, porque quem escolhe esse +cabeçalho é quem faz a requisição. + +--- + +## Ligando + +Entre com a senha local e clique em **Configurações**, no cabeçalho. A janela tem duas abas; a de +OpenID Connect é a que funciona. + +| Campo | O que vai nele | +|---|---| +| Endereço público do painel | `https://painel.exemplo.com` — exato, sem barra no fim | +| Endereço de retorno | é exibido, não digitado. Copie para o provedor | +| Issuer | `https://accounts.google.com`, ou o que seu provedor publicar | +| ID do cliente | da aplicação que você registrar no provedor | +| Segredo do cliente | do mesmo lugar. Só de escrita: veja abaixo | +| Escopos | deixe `openid email profile` a menos que saiba por que não | +| Domínios autorizados | `exemplo.com,filial.exemplo.com` | +| E-mails autorizados | `chefe@exemplo.com` | +| Provedor ativo | mude para OpenID Connect por último, com o resto preenchido | +| Sua senha atual do painel | obrigatória — veja abaixo | + +Três desses campos se comportam de um jeito que vale conhecer. + +**O segredo do cliente nunca volta.** O campo mostra pontinhos quando há um segredo guardado, e +fica vazio quando não há. Salvar com ele em branco MANTÉM o que já estava lá; só se escreve, nunca +se lê. Ele mora num arquivo de permissão `0600` ao lado da credencial de recuperação, e não no banco +de preferências. Você também pode defini-lo no ambiente, em `OIDC_CLIENT_SECRET`: aí o ambiente +vence e o campo fica travado — a mesma regra que `DASHBOARD_PASSWORD` já segue. + +**A lista de autorizados é obrigatória e não pode ficar vazia.** "Entrar com o Google" sem filtro +significa que toda conta Google do planeta entra no seu painel. O painel recusa ligar o SSO com as +duas listas em branco, e recusa concluir uma entrada se de algum modo as encontrar vazias depois. + +**Sua senha atual do painel é pedida de novo.** Você já está autenticado, então isso parece atrito à +toa. Não é: sem ela, uma sessão de oito horas roubada bastaria para apontar o painel a um provedor +hostil e pôr o atacante na lista de autorizados — acesso permanente, que sobrevive a você trocar a +senha. + +--- + +## Configurando no Google Workspace + +1. Abra o console do Google Cloud e escolha (ou crie) um projeto. +2. **APIs e serviços → Tela de consentimento OAuth.** Escolha **Interno** se o painel é só para + gente do seu Workspace; só isso já impede que contas de fora cheguem à tela de consentimento. + Preencha o nome do aplicativo e o e-mail de suporte. +3. **APIs e serviços → Credenciais → Criar credenciais → ID do cliente OAuth.** + Tipo de aplicativo: **Aplicativo da Web**. +4. Em **URIs de redirecionamento autorizados**, cole o *Endereço de retorno* que o painel mostrou. + Ele termina em `/sso/oidc/callback`. Tem de bater caractere por caractere, inclusive o `https` e + a porta, se houver. +5. Salve. O Google exibe o ID e o segredo do cliente uma única vez — copie os dois para o painel + agora. +6. No painel: issuer `https://accounts.google.com`, domínios autorizados = o domínio do seu + Workspace, provedor ativo = OpenID Connect, digite a senha do painel, salve. +7. Saia e olhe a tela de entrada. Abaixo do formulário de senha há agora um botão + **Entrar com accounts.google.com**. + +Se o botão não aparecer, o painel não conseguiu ler o documento de descoberta do provedor. É +problema de rede, não de configuração — o painel esconde o botão de propósito, em vez de oferecer +uma porta que não abre. + +## Entra ID e Okta + +O formato é o mesmo; só os nomes mudam de lugar. + +**Microsoft Entra ID** — registre uma aplicação em *Registros de aplicativo*, acrescente o endereço +de retorno como URI de redirecionamento do tipo **Web**, crie um segredo em *Certificados e +segredos*, e use o issuer que aparece no link do *documento de metadados do OpenID Connect* (algo +como `https://login.microsoftonline.com//v2.0`). Cole sem a parte +`/.well-known/openid-configuration`. + +**Okta** — crie uma *OIDC / Web Application*, aponte a URI de redirecionamento de entrada para o +endereço de retorno, e use a URL da sua organização como issuer (`https://sua-org.okta.com`, ou a +URL do servidor de autorização personalizado, se usar um). + +Nos dois casos, o issuer que você digita tem de ser **exatamente** o valor que o provedor declara +como `issuer` nos metadados dele. O painel compara os dois e recusa seguir se diferirem — é essa +conferência que impede a resposta de um provedor ser aceita como se fosse de outro. + +--- + +## Quando o provedor cai + +Em ordem de quão pouco você precisa fazer: + +1. **Entre com a senha, simplesmente.** O formulário está ali. É a resposta quase sempre, e é por + isso que ele nunca sai da tela. +2. **A credencial de recuperação continua valendo.** `admin` mais o hash de recuperação entra + qualquer que seja o estado do provedor, como descrito em [Authentication](Authentication). +3. **Scripts e monitoramento nem perceberam.** Tudo que não pede HTML — `curl`, cron, seu + monitor de disponibilidade — autentica como sempre autenticou. A entrada federada vale só para a + porta do navegador. +4. **Desligue sem abrir o painel.** Defina `SSO_DISABLED=1` no ambiente e suba o serviço de novo. + Isso vence o que estiver no banco, não toca em nada, e se desfaz removendo a variável. Use quando + o provedor não estiver apenas lento, e sim errado — mandando gente para o lugar errado, ou + recusando um domínio que antes aceitava. + +Para desligar pela tela: Configurações → Provedor ativo → *Desligado (só senha)* → digite a senha do +painel → salvar. + +> **Uma coisa que não é defeito.** *Sair* apaga apenas a sessão do painel. Sua sessão no provedor de +> identidade continua aberta, então o clique seguinte no botão de SSO pode entrar direto, sem pedir +> nada. Logout federado não existe nesta versão. + +--- + +## O que é conferido na volta + +Quando o provedor devolve o navegador, o painel recusa na primeira coisa que não fecha, e toda +recusa produz **a mesma mensagem na tela**. Dizer se foi o estado ou a lista de autorizados também +diria ao atacante até onde ele chegou; o motivo real vai para o log. + +| Conferido | Por que está na lista | +|---|---| +| Teto de tentativas por endereço | as duas rotas novas são públicas, então levam o mesmo teto do formulário | +| Cookie de estado presente e com assinatura íntegra | sem a assinatura, quem escreve os dois lados casa sempre | +| Estado da URL igual ao do cookie | é o que impede o CSRF de login — ser autenticado na conta do atacante | +| Estado ainda não usado | além do cookie, o servidor lembra o que consumiu: uso único de verdade | +| Sem erro, e com código presente | | +| Código trocado com prova de posse da chave | o código passa pela barra de endereços e fica no histórico; a chave nunca sai do cookie | +| `iss` igual ao issuer configurado | um provedor por vez, então há uma única resposta certa | +| `aud` contendo o ID do cliente | um token legítimo emitido para OUTRO serviço não pode valer aqui | +| Não vencido, e emitido há menos de cinco minutos | tolerância generosa transforma prazo em decoração | +| Nonce igual ao do cookie | amarra o token àquela ida específica | +| As informações do usuário e o token descrevem o mesmo sujeito | prova que a troca aconteceu de verdade | +| E-mail confirmado pelo provedor | senão um endereço não verificado basta para entrar | +| E-mail ou domínio na lista de autorizados | | + +Só então a sessão é emitida — **o mesmo cookie assinado** que o formulário de senha emite, com a +mesma validade de oito horas e as mesmas propriedades. Não existe um segundo tipo de sessão. + +A página de pouso é uma página de verdade com um redirecionamento dentro, e não um redirecionamento +HTTP: navegadores não carregam um cookie estrito de mesmo site por uma cadeia de redirecionamento +que começou em outro site, e você aterrissaria de volta no formulário com uma sessão perfeitamente +boa no bolso. O destino é sempre a raiz do painel — nenhum parâmetro da URL vira destino. + +Cada uma dessas recusas tem um teste que a exercita com entrada ruim, em +`tests/test_sso_oidc.py`. + +--- + +## SAML 2.0 não está nesta versão + +A aba existe, os campos existem, e estão desabilitados. É um limite técnico, não uma tarde de +trabalho que faltou. + +O SAML assina sobre *Exclusive XML Canonicalization 1.0*. A biblioteca padrão do Python também +canonicaliza XML — mas em C14N 2.0, outro algoritmo, o que faz toda assinatura válida parecer +inválida. A biblioteca padrão também não tem verificação RSA nenhuma, e o analisador de XML dela não +se defende de *signature wrapping*, o ataque específico deste protocolo, em que a asserção assinada +é movida para outro ponto do documento e uma falsa ocupa o lugar lido. + +Escrever isso à mão produz uma validação que parece funcionar e aceita asserção forjada em silêncio. +Então o suporte a SAML espera uma biblioteca: `python3-saml` está declarado no `pyproject.toml` como +extra opcional `saml`, e a imagem precisará de `libxml2`, `libxslt` e `xmlsec` antes que ele possa +ser ligado. Até lá o painel recusa ligá-lo, e diz isso. + +Também fora de escopo nesta versão, por decisão: logout federado e asserção SAML criptografada. + +--- + +## Onde cada coisa fica guardada + +| O quê | Onde | Por que ali | +|---|---|---| +| Issuer, ID do cliente, endereço público, listas de autorizados | banco de preferências do painel, chaves com prefixo `sso.` | nada disso é segredo; é a mesma tabela do idioma | +| Segredo do cliente | arquivo de permissão `0600` no diretório de dados | segredo não mora em banco que a tela lê | +| Qual provedor está ativo | preferências, um por vez | dois provedores ao mesmo tempo é como a resposta de um passa por outro | +| O estado da ida | cookie assinado de vida curta, mais uma lista de estados consumidos em memória | morre em dez minutos e sozinho não dá acesso a nada | + +O diretório de dados é o mesmo que o resto do painel já usa — veja `DATA_DIR` em +[Configuration](Configuration). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index e60cbf0..856bc2d 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -10,11 +10,12 @@ Concrete symptoms, what they actually mean, and what to do. `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: +The dashboard states this per connection, in the details modal the **Details** button on the +connections table opens: > Outside the 15 min margin: renewal expected in ~9 min -**When it *is* a problem:** the diagnosis column says something else. +**When it *is* a problem:** the diagnosis in that modal says something else. | Diagnosis | Meaning | Action | | :--- | :--- | :--- | @@ -33,7 +34,7 @@ REFRESH_MARGIN=1800 # renew during the last 30 minutes ## `BrokenPipeError: [Errno 32] Broken pipe` in `serve_healthz` ``` -File "/app/src/omini_rtksync/web/server.py", line 116, in serve_healthz +File "/app/src/omini_rtksync/web.py", line 236, in do_GET self.wfile.write(b"OK") BrokenPipeError: [Errno 32] Broken pipe ``` @@ -89,7 +90,7 @@ If the numbers still look wrong, the synchronizer may not be writing at all — Sign in with user `admin` and the **recovery hash** as the password. Find it with: ```bash -docker logs ominirtksync 2>&1 | grep "Recovery hash" +docker logs ominirtk-sync 2>&1 | grep "Recovery hash" # or, if the log file is mounted: grep "Recovery hash" /app/data/logs/ominirtksync.log ``` @@ -97,7 +98,7 @@ grep "Recovery hash" /app/data/logs/ominirtksync.log If the log has already rotated past it, the value is on disk: ```bash -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` To pin your own instead of relying on the generated one, set `DASHBOARD_RECOVERY_HASH` and diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 6527ce9..388e60b 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -5,9 +5,11 @@ - [Configuration](Configuration) - [Dashboard](Dashboard) - [Authentication](Authentication) +- [Single Sign-On](Single-Sign-On) - [Logging](Logging) - [Architecture](Architecture) - [Egress and Multi-Session](Egress-And-Multi-Session) +- [Licensing and Capacity](Licensing-And-Capacity) - [Remote Access](Remote-Access) - [Egress Testing](Egress-Testing) - [Troubleshooting](Troubleshooting) diff --git a/pyproject.toml b/pyproject.toml index 69f3fdb..6d1b56f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,15 @@ classifiers = [ "Topic :: Utilities", ] +# SAML 2.0 nao entra no fluxo padrao: exige uma biblioteca que assine e confira +# XML (Exclusive XML Canonicalization 1.0, verificacao RSA e defesa contra XML +# Signature Wrapping), e nada disso existe na biblioteca padrao do Python. O +# OpenID Connect, ao contrario, e implementado com stdlib pura e nao depende de +# nada declarado aqui. Enquanto esta dependencia nao estiver na imagem, a aba +# SAML aparece desabilitada e o painel recusa liga-lo. +[project.optional-dependencies] +saml = ["python3-saml>=1.16"] + [project.urls] Homepage = "https://github.com/pathbit/OminiRTkSync" Repository = "https://github.com/pathbit/OminiRTkSync" @@ -33,3 +42,10 @@ omini-rtksync = "omini_rtksync.cli:main" [tool.setuptools.packages.find] where = ["src"] + +# A suite tem de rodar sem depender de `pip install -e`. Sem isto, o repo +# que por acaso tem uma instalacao editavel na maquina passa e o irmao +# reprova na coleta -- com o mesmo codigo. Foi o que aconteceu aqui. +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/omini_rtksync/__init__.py b/src/omini_rtksync/__init__.py index 5fb26a4..6de0d3a 100644 --- a/src/omini_rtksync/__init__.py +++ b/src/omini_rtksync/__init__.py @@ -1,7 +1,7 @@ -"""OminiRTKSync: OmniRoute Universal Token & Connection Sync. +"""Sincronizador universal de tokens e conexoes de um gateway de IA. -Specialized token keeper, health validator and auto-healer for OmniRoute AI Gateways -(https://github.com/diegosouzapw/OmniRoute). +Specialized token keeper, health validator and auto-healer for the AI gateway +it is paired with. """ __version__ = "1.0.0" diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index e4a9443..0850cf1 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -1,4 +1,4 @@ -"""CLI e orquestrador do OminiRTKSync para OmniRoute.""" +"""CLI e orquestrador deste sincronizador.""" import argparse import os @@ -6,24 +6,32 @@ import sys import threading import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List from .config import Settings -from .credential_check import STATE_INVALID, STATE_VALID, check_oauth_token +from .identidade import NOME_DO_GATEWAY, NOME_DO_PRODUTO +from .credential_check import ( + STATE_INVALID, + STATE_UNSUPPORTED, + STATE_VALID, + CheckResult, + check_oauth_token, + looks_encrypted, +) from .logs import get_logger, setup_logging from .cron import CronScheduler -from .database import ( +from .gateway import ( get_all_combos, get_all_connections, normalize_expiry_format, update_connection, update_connection_health, ) -from .discovery import HostDiscoveryEngine +from .gateway import HostDiscoveryEngine from .normalizer import parse_expiry_to_ms -from .providers import ApiKeyProvider, GenericOAuthProvider, GoogleProvider, LocalProvider -from .web import start_omini_web +from .gateway import ApiKeyProvider, GenericOAuthProvider, GoogleProvider, LocalProvider +from .web import start_web_server # Prefixos que descrevem falha. Emitir tudo em INFO fazia com que @@ -82,11 +90,19 @@ def sync_all(self): def _sync_all_locked(self): if not os.path.exists(self.settings.db_path): - log_msg("AVISO", f"Aguardando banco do OmniRoute em: {self.settings.db_path}") - return {"success": False, "error": "db_not_found"} + # O gateway cria o banco ao ser usado pela primeira vez. Ate la, + # o arquivo nao existir e o estado NORMAL de uma stack recem + # subida -- nao uma falha. Relatar como erro pintava o painel de + # vermelho no primeiro minuto de uso e ensinava o operador a + # ignorar o indicador, que e o oposto do que ele serve. + log_msg("INFO", f"Aguardando o gateway criar o banco em: {self.settings.db_path}") + return {"success": True, "waiting_for_gateway": True, + "total_connections": 0, "refreshed": 0, "normalized": 0, + "combos_synced": 0, "details": [], + "timestamp": datetime.now(timezone.utc).isoformat()} conns = get_all_connections(self.settings.db_path) - log_msg("INFO", f"Inspecionando {len(conns)} conexões no OmniRoute ({self.settings.db_path})...") + log_msg("INFO", f"Inspecionando {len(conns)} conexões no gateway ({self.settings.db_path})...") refreshed = 0 normalized = 0 @@ -110,7 +126,7 @@ def _sync_all_locked(self): # Cura o formato de expiração para QUALQUER provedor OAuth, não só # para o ramo do Antigravity. Um epoch numérico em texto é Invalid - # Date para o OmniRoute; se a renovação falhar — refresh token + # Date para o gateway; se a renovação falhar — refresh token # revogado, client credentials ausentes — o valor ilegível # permanecia para sempre justamente no caso em que mais importa. bruto_expiracao = str(c.get("expiresAt") or "") @@ -148,9 +164,24 @@ def _sync_all_locked(self): # gravada ainda está no futuro continuava sendo exibido como # ativo — que é exatamente o caso que o painel precisa mostrar. if self.settings.validate_credentials and c.get("accessToken"): - veredito = check_oauth_token( - str(c.get("accessToken")), timeout=self.settings.validation_timeout - ) + # Credencial cifrada em repouso não é credencial inválida: + # o que temos em mãos é um texto que não sabemos abrir. + # Sondar com ele só produz uma recusa do provedor, e gravar + # essa recusa marcava de vermelho, no painel do próprio + # gateway, uma conta que ninguém chegou a testar. + if looks_encrypted(c.get("accessToken")): + nota = "Token cifrado em repouso pelo gateway: não verificável daqui" + log_msg("INFO", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) + veredito = CheckResult( + state=STATE_UNSUPPORTED, + detail="Access token is encrypted at rest by the gateway", + ) + else: + veredito = check_oauth_token( + str(c.get("accessToken")), timeout=self.settings.validation_timeout + ) + if veredito.state == STATE_INVALID: nota = f"Token de acesso RECUSADO pelo Google ({veredito.detail})" log_msg("FALHA", f"[{provider} · {name}] {nota}") @@ -168,7 +199,7 @@ def _sync_all_locked(self): # Gravar só o resultado da sonda deixava para trás o # `test_status='invalid'` de uma falha anterior, que não # caduca sozinho. 'active' é o único valor que o - # OmniRoute trata como saudável (clearAccountError), e é + # o gateway trata como saudável (clearAccountError), e é # o que a credencial acabou de provar que é. update_connection_health( self.settings.db_path, @@ -342,7 +373,7 @@ def run_daemon(settings: Settings): def handle_signal(sig, frame): nonlocal running - print(f"\n[!] Sinal {sig} recebido. Encerrando OminiRTKSync...", flush=True) + print(f"\n[!] Sinal {sig} recebido. Encerrando {NOME_DO_PRODUTO}...", flush=True) running = False signal.signal(signal.SIGINT, handle_signal) @@ -368,12 +399,12 @@ def handle_signal(sig, frame): cron_scheduler = CronScheduler( sync_callback=engine.sync_all, interval_seconds=settings.cron_interval, - name="OminiRTKSync-CronScheduler", + name=f"{NOME_DO_PRODUTO}-CronScheduler", ) if settings.enable_web: try: - start_omini_web( + start_web_server( settings.web_host, settings.web_port, settings.db_path, @@ -395,16 +426,16 @@ def handle_signal(sig, frame): time.sleep(1) cron_scheduler.stop() - print("[*] OminiRTKSync encerrado.", flush=True) + print(f"[*] {NOME_DO_PRODUTO} encerrado.", flush=True) def main(): parser = argparse.ArgumentParser( prog="ominirtksync", - description="OminiRTKSync · OmniRoute Universal Token & Connection Sync", + description=f"{NOME_DO_PRODUTO} · {NOME_DO_GATEWAY} Universal Token & Connection Sync", ) - parser.add_argument("--db-path", dest="db_path", help="Caminho para o storage.sqlite do OmniRoute") - parser.add_argument("--status", action="store_true", help="Exibe status das conexões do OmniRoute e sai") + parser.add_argument("--db-path", dest="db_path", help=f"Caminho para o storage.sqlite do {NOME_DO_GATEWAY}") + parser.add_argument("--status", action="store_true", help=f"Exibe status das conexões do {NOME_DO_GATEWAY} e sai") parser.add_argument("--once", action="store_true", help="Executa uma rodada única de sincronização e sai") parser.add_argument("--daemon", action="store_true", help="Executa em modo daemon perpétuo") parser.add_argument("--interval", type=int, help="Intervalo de checagem em segundos (padrão: 300)") @@ -412,7 +443,15 @@ def main(): parser.add_argument("--no-web", action="store_true", help="Desativa dashboard web") parser.add_argument("--port", type=int, help="Porta do dashboard web (padrão: 9090)") parser.add_argument("--user", type=str, help="Usuário para autenticação no dashboard web (padrão: admin)") - parser.add_argument("--password", type=str, help="Senha para autenticação no dashboard web (padrão: pathbit)") + # Sem citar valor: um texto de --help é arquivo versionado, e uma senha de + # fábrica anunciada ali vira a senha real de toda instalação que copiou e + # colou. O padrão, além disso, não é "pathbit" -- é vazio (config.py), e o + # painel gera uma credencial de recuperação no primeiro boot. + parser.add_argument( + "--password", + type=str, + help="Senha para autenticação no dashboard web (sem padrão: defina DASHBOARD_PASSWORD)", + ) args = parser.parse_args() settings = Settings.from_env() @@ -459,7 +498,7 @@ def main(): if args.once: engine = OmniSyncEngine(settings) res = engine.sync_all() - print(f"[*] Sincronização OmniRoute concluída: {res.get('total', 0)} conexões inspecionadas, {res.get('refreshed', 0)} renovadas.") + print(f"[*] Sincronização do {NOME_DO_GATEWAY} concluída: {res.get('total', 0)} conexões inspecionadas, {res.get('refreshed', 0)} renovadas.") return run_daemon(settings) diff --git a/src/omini_rtksync/config.py b/src/omini_rtksync/config.py index 6edb92b..b42fded 100644 --- a/src/omini_rtksync/config.py +++ b/src/omini_rtksync/config.py @@ -1,4 +1,4 @@ -"""Configurações globais e carregamento de variáveis de ambiente para o OminiRTKSync.""" +"""Configurações globais e carregamento de variáveis de ambiente deste sincronizador.""" import os from dataclasses import dataclass @@ -39,7 +39,7 @@ def load_dotenv(dotenv_path: str = ".env") -> None: @dataclass class Settings: - """Configurações de execução do OminiRTKSync para OmniRoute.""" + """Configurações de execução deste sincronizador.""" db_path: str host_home: str = "" omniroute_url: str = "http://127.0.0.1:20128" @@ -215,7 +215,7 @@ def from_env(cls, env_file: str = ".env") -> "Settings": ] valid_paths = [p for p in default_paths if p] - # Descoberta de banco SQLite do OmniRoute + # Descoberta do banco SQLite do gateway db_path = os.environ.get("DB_PATH", "") if not db_path: candidate_dbs = [ diff --git a/src/omini_rtksync/credential_check.py b/src/omini_rtksync/credential_check.py index 3f65ed0..debb9a6 100644 --- a/src/omini_rtksync/credential_check.py +++ b/src/omini_rtksync/credential_check.py @@ -31,8 +31,10 @@ from datetime import datetime, timezone from typing import Any, Callable, Dict, Optional +from .identidade import NOME_DO_PRODUTO + DEFAULT_TIMEOUT_SECONDS = 8.0 -USER_AGENT = "OminiRTKSync-CredentialCheck/1.0" +USER_AGENT = f"{NOME_DO_PRODUTO}-CredentialCheck/1.0" # States a probe can conclude. "not_checked" is the absence of a probe. STATE_VALID = "valid" @@ -255,6 +257,31 @@ def check_oauth_token( return _execute(request, timeout, opener, spec_invalid=(400,)) + +# Prefixo com que o gateway marca uma credencial cifrada em repouso +# (AES-256-GCM, formato enc:v1:::). Ler esse valor cru e +# manda-lo ao provedor so produz uma recusa que nao diz nada sobre a +# credencial -- diz sobre a nossa incapacidade de le-la. +ENCRYPTED_PREFIX = "enc:" + + +def looks_encrypted(value: Any) -> bool: + """True quando o valor guardado e um texto cifrado, nao a credencial.""" + return isinstance(value, str) and value.startswith(ENCRYPTED_PREFIX) + + +def _unreadable(campo: str) -> "CheckResult": + """Resultado honesto para o que nao conseguimos sequer ler.""" + return CheckResult( + state=STATE_UNSUPPORTED, + detail=( + f"{campo} is encrypted at rest by the gateway; " + "not verifiable from here" + ), + checked_at=_now_iso(), + ) + + def check_connection( conn: Any, timeout: float = DEFAULT_TIMEOUT_SECONDS, @@ -270,9 +297,13 @@ def check_connection( ) if getattr(conn, "is_oauth", False) and getattr(conn, "access_token", None): + if looks_encrypted(conn.access_token): + return _unreadable("Access token") return check_oauth_token(conn.access_token, timeout=timeout, opener=opener) if getattr(conn, "has_api_key", False): + if looks_encrypted(getattr(conn, "api_key", None)): + return _unreadable("API key") return check_api_key( conn.provider, conn.api_key or "", diff --git a/src/omini_rtksync/cron.py b/src/omini_rtksync/cron.py index 9e411cd..b3d02fb 100644 --- a/src/omini_rtksync/cron.py +++ b/src/omini_rtksync/cron.py @@ -1,10 +1,11 @@ -"""Motor de agendamento em background (CronScheduler) para o OminiRTKSync.""" +"""Motor de agendamento em background (CronScheduler) do painel.""" import threading import time from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional +from .identidade import NOME_DO_PRODUTO from .logs import get_logger @@ -33,13 +34,13 @@ def _extract_log_lines(res: Any) -> List[str]: class CronScheduler: - """Agendador em background que gerencia a renovação contínua de contas OAuth e integridade de conexões no OmniRoute.""" + """Agendador em background da renovacao continua de contas OAuth e da saude das conexoes.""" def __init__( self, sync_callback: Callable[[], Dict[str, Any]], interval_seconds: int = 300, - name: str = "OminiRTKSync-Cron", + name: str = f"{NOME_DO_PRODUTO}-Cron", ): self.sync_callback = sync_callback self.interval_seconds = max(10, interval_seconds) @@ -49,7 +50,7 @@ def __init__( self._stop_event = threading.Event() self._lock = threading.Lock() - # Métricas + # Metricas de execucao self.total_runs = 0 self.total_renewals = 0 self.last_run_at: Optional[str] = None @@ -58,6 +59,7 @@ def __init__( self.history: List[Dict[str, Any]] = [] def start(self): + """Sobe a thread de cron em background.""" with self._lock: if self.is_running: return @@ -68,14 +70,17 @@ def start(self): self._thread.start() def stop(self): + """Encerra a thread de cron sem violencia.""" with self._lock: self.is_running = False self._stop_event.set() def trigger_now(self) -> Dict[str, Any]: + """Dispara um ciclo de sincronizacao agora, de forma sincrona.""" return self._execute_cycle(reason="manual_trigger") def get_status(self) -> Dict[str, Any]: + """Retrato detalhado do agendador para a API e para o painel.""" with self._lock: return { "active": self.is_running, @@ -97,7 +102,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") - get_logger().info(f"[CRON] Ciclo disparado ({reason}). Inspecionando conexoes de contas OAuth no OmniRoute...") + get_logger().info(f"[CRON] Ciclo disparado ({reason}). Inspecionando conexoes de contas OAuth...") try: res = self.sync_callback() @@ -106,7 +111,7 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: duration_ms = int((time.time() - start_ts) * 1000) refreshed = res.get("refreshed", 0) if isinstance(res, dict) else 0 - total = res.get("total", 0) if isinstance(res, dict) else 0 + total = res.get("total_connections", res.get("total", 0)) if isinstance(res, dict) else 0 entry = { "timestamp": start_iso, diff --git a/src/omini_rtksync/database.py b/src/omini_rtksync/database.py deleted file mode 100644 index 92a887e..0000000 --- a/src/omini_rtksync/database.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Acesso e mutação segura do banco SQLite do OmniRoute (storage.sqlite).""" - -import json -import os -import sqlite3 -import time -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional - - -def get_db_connection(db_path: str) -> sqlite3.Connection: - if not os.path.exists(db_path): - raise FileNotFoundError(f"Banco SQLite do OmniRoute não encontrado em: {db_path}") - conn = sqlite3.connect(db_path, timeout=15.0) - conn.row_factory = sqlite3.Row - return conn - - -def detect_connection_table(conn: sqlite3.Connection) -> str: - """Detecta se o OmniRoute utiliza a tabela provider_connections ou providerConnections.""" - c = conn.cursor() - c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('provider_connections', 'providerConnections')") - row = c.fetchone() - if row: - return row[0] - return "provider_connections" - - -def _decode_json(value: Any) -> Optional[Dict[str, Any]]: - """Le uma coluna JSON tolerando texto vazio, NULL e dicionario ja decodificado.""" - if isinstance(value, dict): - return value - if not value: - return None - try: - decoded = json.loads(value) - except (TypeError, ValueError): - return None - return decoded if isinstance(decoded, dict) else None - - -def _anexar_saida_de_rede(conn: sqlite3.Connection, conexoes: List[Dict[str, Any]]) -> None: - """Resolve, por conexao, qual saida de rede o OmniRoute usaria. - - O vinculo vive em ``proxy_assignments`` com ``scope='account'`` e - ``scope_id`` igual ao id da conexao; o proxy em si esta em - ``proxy_registry``. Tudo em uma consulta so, e em silencio quando a - instalacao e antiga demais para ter essas tabelas. - - Somente leitura: nada aqui escreve no banco. - """ - try: - existentes = { - r[0] - for r in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' " - "AND name IN ('proxy_assignments','proxy_registry')" - ) - } - if {"proxy_assignments", "proxy_registry"} - existentes: - return - - vinculos: Dict[str, str] = {} - for linha in conn.execute( - "SELECT a.scope_id, COALESCE(NULLIF(r.name,''), r.host || ':' || r.port) AS saida " - "FROM proxy_assignments a JOIN proxy_registry r ON r.id = a.proxy_id " - "WHERE a.scope = 'account' AND a.scope_id IS NOT NULL " - "ORDER BY a.position ASC" - ): - # position ASC e o primeiro vence: e a saida que a rotacao entrega - # quando o escopo tem um unico proxy vinculado. - vinculos.setdefault(str(linha[0]), str(linha[1])) - - for c in conexoes: - saida = vinculos.get(c["id"]) - if saida: - c["egressProxy"] = saida - except sqlite3.Error: - # Schema mais antigo ou banco em uso por outro processo: a coluna de - # saida simplesmente nao aparece, sem derrubar a listagem inteira. - return - - -def get_all_connections(db_path: str) -> List[Dict[str, Any]]: - """Carrega todas as conexões cadastradas no OmniRoute.""" - conn = get_db_connection(db_path) - try: - tbl = detect_connection_table(conn) - cursor = conn.cursor() - cursor.execute(f"SELECT * FROM {tbl}") - rows = cursor.fetchall() - result = [] - for r in rows: - keys = r.keys() - item = dict(r) - - # Normalização de nomes de colunas relacionais - provider = item.get("provider", "") - name = item.get("name") or item.get("display_name") or provider - access_token = item.get("access_token") or item.get("accessToken") - refresh_token = item.get("refresh_token") or item.get("refreshToken") - api_key = item.get("api_key") or item.get("apiKey") - expires_at = item.get("expires_at") or item.get("expiresAt") - test_status = item.get("test_status") or item.get("testStatus") or "active" - - # Se houver campo JSON 'data' (formato 9Router), funde os campos - extra: Dict[str, Any] = {} - if "data" in keys and isinstance(item["data"], str): - try: - d = json.loads(item["data"]) - access_token = access_token or d.get("accessToken") - refresh_token = refresh_token or d.get("refreshToken") - api_key = api_key or d.get("apiKey") - expires_at = expires_at or d.get("expiresAt") - test_status = test_status or d.get("testStatus") - if isinstance(d, dict): - extra = d - except Exception: - pass - - # provider_specific_data e onde o OmniRoute guarda baseUrl e afins. - specific = _decode_json(item.get("provider_specific_data")) or _decode_json( - extra.get("providerSpecificData") - ) - - result.append({ - "id": str(item["id"]), - "provider": provider, - "name": name, - "accessToken": access_token, - "refreshToken": refresh_token, - "apiKey": api_key, - "expiresAt": expires_at, - "testStatus": test_status, - "isOAuth": bool(access_token or refresh_token), - "hasApiKey": bool(api_key), - # Campos de saude, projetados um a um. A linha crua do banco NAO - # e devolvida: ela carrega access_token, refresh_token e api_key, - # e qualquer consumidor que a serializasse por engano publicaria - # as tres coisas de uma vez. - "providerSpecificData": specific, - "baseUrl": (specific or {}).get("baseUrl") or (specific or {}).get("baseURL"), - "discoveredModels": (specific or {}).get("discoveredModels") - or extra.get("discoveredModels") - or [], - "credentialState": (specific or {}).get("credentialState") - or extra.get("credentialState"), - "lastTested": item.get("last_tested") or extra.get("lastTested"), - # Renovar e verificar são eventos diferentes. Sem projetar este - # campo, "última renovação" no painel caía para o horário da - # última verificação e um token parado há dias parecia recém - # renovado a cada ciclo. - "lastRefreshAt": (specific or {}).get("lastRefreshAt") or extra.get("lastRefreshAt"), - "lastHealthCheckAt": item.get("last_health_check_at"), - "rateLimitedUntil": item.get("rate_limited_until") or extra.get("rateLimitedUntil"), - "lastError": item.get("last_error"), - "updatedAt": item.get("updated_at") or item.get("updatedAt"), - # Saida de rede: interruptores por conexao do proprio OmniRoute. - # O vinculo em si vem de proxy_assignments, resolvido abaixo. - "proxyEnabled": bool(item.get("proxy_enabled")), - "perKeyProxyEnabled": bool(item.get("per_key_proxy_enabled")), - "egressProxy": None, - }) - - _anexar_saida_de_rede(conn, result) - return result - finally: - conn.close() - - -def to_iso_utc(epoch_ms: int) -> str: - """Converte epoch em milissegundos para o ISO-8601 em UTC que o OmniRoute grava nativamente.""" - return ( - datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc) - .isoformat(timespec="milliseconds") - .replace("+00:00", "Z") - ) - - -def normalize_expiry_format(db_path: str, connection_id: str, expires_at_ms: int) -> bool: - """Regrava expires_at em ISO-8601 sem tocar nos tokens. - - A cura de formato acontecia so junto de uma renovacao bem-sucedida. Quando a - renovacao falha -- refresh token revogado, client_id ausente -- o epoch - numerico gravado como texto permanecia, e e justamente ele que o OmniRoute - le com `new Date(...)` e obtem Invalid Date, desligando a propria renovacao - preventiva. O formato e curado de qualquer jeito. - """ - conn = get_db_connection(db_path) - now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - try: - tbl = detect_connection_table(conn) - cursor = conn.cursor() - cursor.execute( - f"UPDATE {tbl} SET expires_at = ?, updated_at = ? WHERE id = ?", - (to_iso_utc(expires_at_ms), now_iso, connection_id), - ) - conn.commit() - return cursor.rowcount > 0 - except sqlite3.Error: - return False - finally: - conn.close() - -def update_connection( - db_path: str, connection_id: str, access_token: str, refresh_token: str, expires_at_ms: int -) -> bool: - """Atualiza as credenciais normalizadas na tabela detectada.""" - conn = get_db_connection(db_path) - tbl = detect_connection_table(conn) - now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - try: - cursor = conn.cursor() - cursor.execute(f"PRAGMA table_info({tbl})") - cols = [c["name"] for c in cursor.fetchall()] - - if "access_token" in cols: - # Tabela relacional do OmniRoute (provider_connections). - # - # expires_at é uma coluna TEXT e o OmniRoute a lê com `new Date(...)` - # (src/lib/tokenHealthCheck.ts). Um epoch numérico gravado como texto - # vira Invalid Date -> NaN -> o health check conclui que a conexão não - # tem expiração conhecida e nunca renova o token preventivamente. - # Por isso gravamos ISO-8601, o mesmo formato nativo do gateway. - # - # test_status precisa ser 'active': é o único valor que o OmniRoute - # trata como saudável (src/sse/services/auth.ts::clearAccountError e - # tokenHealthCheck.ts). 'ok' não é reconhecido e faz a conexão parecer - # estar em estado de erro. - # O horário da renovação vive no JSON de provider_specific_data: - # o schema relacional não tem coluna para ele, e sem esse carimbo o - # painel não distingue "renovado agora" de "apenas verificado". - especifico = {} - if "provider_specific_data" in cols: - cursor.execute( - f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,) - ) - linha = cursor.fetchone() - if linha: - especifico = _decode_json(linha["provider_specific_data"]) or {} - especifico["lastRefreshAt"] = now_iso - cursor.execute( - f""" - UPDATE {tbl} - SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', - provider_specific_data = ?, updated_at = ? - WHERE id = ? - """, - ( - access_token, - refresh_token, - to_iso_utc(expires_at_ms), - json.dumps(especifico), - now_iso, - connection_id, - ), - ) - else: - cursor.execute( - f""" - UPDATE {tbl} - SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? - WHERE id = ? - """, - (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), - ) - elif "data" in cols: - # Formato compatível com JSON - cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) - row = cursor.fetchone() - d = {} - if row and row["data"]: - try: - d = json.loads(row["data"]) - except Exception: - pass - d["accessToken"] = access_token - if refresh_token: - d["refreshToken"] = refresh_token - d["expiresAt"] = expires_at_ms - # 'active' é o valor que dispara o reset de estado de saúde no - # 9Router (resetHealthStateOnActivation em connectionsRepo.js); - # 'ok' só é reconhecido pela UI e não limpa travas de erro. - d["testStatus"] = "active" - d["lastRefreshAt"] = now_iso - cursor.execute( - f"UPDATE {tbl} SET data = ?, updatedAt = ? WHERE id = ?", - (json.dumps(d), now_iso, connection_id), - ) - conn.commit() - return cursor.rowcount > 0 - finally: - conn.close() - - -def update_connection_health( - db_path: str, - connection_id: str, - *, - test_status: Optional[str] = None, - credential_state: Optional[str] = None, - discovered_models: Optional[List[str]] = None, - last_error: Optional[str] = None, - clear_rate_limit: bool = False, -) -> bool: - """Grava o resultado de uma sondagem, sem tocar em token nenhum. - - Antes disto o provider devolvia `testStatus`, `credentialState` e o catalogo - descoberto, e o motor jogava tudo fora: o painel recarregava a linha antiga e - nunca mostrava que uma chave havia sido recusada nem que a instancia local - estava fora do ar. - - Só escreve em coluna que existe no banco — o schema do OmniRoute evolui entre - versões, e uma instalação mais antiga não pode quebrar por causa disso. - """ - conn = get_db_connection(db_path) - try: - tbl = detect_connection_table(conn) - cursor = conn.cursor() - cols = {c[1] for c in cursor.execute(f"PRAGMA table_info({tbl})")} - now_iso = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") - - campos: List[str] = [] - valores: List[Any] = [] - - if test_status and "test_status" in cols: - campos.append("test_status = ?") - valores.append(test_status) - if "last_tested" in cols: - campos.append("last_tested = ?") - valores.append(now_iso) - if "last_health_check_at" in cols: - campos.append("last_health_check_at = ?") - valores.append(now_iso) - if last_error is not None and "last_error" in cols: - campos.append("last_error = ?") - valores.append(last_error or None) - if clear_rate_limit and "rate_limited_until" in cols: - campos.append("rate_limited_until = ?") - valores.append(None) - - # credentialState e o catalogo local nao tem coluna propria: vao para o - # JSON de provider_specific_data, preservando o que ja estava la. - if (credential_state or discovered_models is not None) and "provider_specific_data" in cols: - cursor.execute(f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,)) - linha = cursor.fetchone() - atual = _decode_json(linha["provider_specific_data"]) if linha else None - atual = dict(atual or {}) - if credential_state: - atual["credentialState"] = credential_state - atual["credentialCheckedAt"] = now_iso - if discovered_models is not None: - atual["discoveredModels"] = discovered_models - campos.append("provider_specific_data = ?") - valores.append(json.dumps(atual)) - - # Schema de coluna JSON única (o formato que o 9Router usa e que o - # OmniRoute aceita em instalações migradas). Sem este ramo a sondagem - # era descartada inteira nessas bases: não há `test_status` nem - # `provider_specific_data` para receber os campos acima, e a função - # saía por `if not campos` como se não houvesse nada a gravar. - if "data" in cols and "test_status" not in cols: - cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) - linha = cursor.fetchone() - d: Dict[str, Any] = {} - if linha and linha["data"]: - try: - carregado = json.loads(linha["data"]) - if isinstance(carregado, dict): - d = carregado - except Exception: - # Coluna corrompida ou em formato inesperado: seguimos com o - # dicionário vazio e regravamos a linha com os campos de - # saúde. Abortar aqui faria uma linha ilegível bloquear para - # sempre a gravação da sondagem — justamente na conexão que - # mais precisa ser diagnosticada. - pass - if test_status: - d["testStatus"] = test_status - if credential_state: - d["credentialState"] = credential_state - d["credentialCheckedAt"] = now_iso - if discovered_models is not None: - d["discoveredModels"] = discovered_models - if last_error is not None: - d["lastError"] = last_error or None - if clear_rate_limit: - d.pop("rateLimitedUntil", None) - d["lastTested"] = now_iso - campos.append("data = ?") - valores.append(json.dumps(d)) - - if "updated_at" in cols: - campos.append("updated_at = ?") - valores.append(now_iso) - elif "updatedAt" in cols: - campos.append("updatedAt = ?") - valores.append(now_iso) - - if not campos: - return False - - valores.append(connection_id) - cursor.execute(f"UPDATE {tbl} SET {', '.join(campos)} WHERE id = ?", valores) - conn.commit() - return cursor.rowcount > 0 - finally: - conn.close() - - -def get_all_combos(db_path: str) -> List[Dict[str, Any]]: - """Carrega combos cadastrados no OmniRoute se a tabela existir.""" - conn = get_db_connection(db_path) - try: - cursor = conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='combos'") - if not cursor.fetchone(): - return [] - cursor.execute("SELECT * FROM combos") - rows = cursor.fetchall() - result = [] - for r in rows: - item = dict(r) - models_raw = item.get("models", "[]") - try: - models = json.loads(models_raw) if isinstance(models_raw, str) else models_raw - except Exception: - models = [] - result.append({ - "id": str(item.get("id")), - "name": item.get("name"), - "kind": item.get("kind", "llm"), - "models": models, - }) - return result - finally: - conn.close() diff --git a/src/omini_rtksync/discovery.py b/src/omini_rtksync/discovery.py deleted file mode 100644 index 831affc..0000000 --- a/src/omini_rtksync/discovery.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Motor de descoberta universal de credenciais locais no host para o OminiRTKSync.""" - -import json -import os -import re -from typing import Any, Dict, List, Optional - - -class HostDiscoveryEngine: - """ - Localiza e extrai credenciais de ferramentas e CLIs instaladas no host. - Funciona tanto executando nativamente no host quanto dentro do container - com o diretório montado em HOST_HOME (ex: /root/host). - """ - - def __init__(self, host_home: Optional[str] = None, extra_paths: Optional[List[str]] = None): - self.host_home = self._resolve_host_home(host_home) - self.extra_paths = extra_paths or [] - - @staticmethod - def _resolve_host_home(override: Optional[str] = None) -> str: - if override and os.path.exists(override): - return override - env_host = os.environ.get("HOST_HOME") - if env_host and os.path.exists(env_host): - return env_host - if os.path.exists("/root/host") and os.path.isdir("/root/host"): - return "/root/host" - if os.path.exists("/host") and os.path.isdir("/host"): - return "/host" - return os.path.expanduser("~") - - def _read_json(self, path: str) -> Optional[Dict[str, Any]]: - if not os.path.exists(path) or not os.path.isfile(path): - return None - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else None - except Exception: - return None - - def discover_google(self) -> Optional[Dict[str, Any]]: - """Descobre tokens do Google Antigravity / Gemini CLI.""" - # 1. jetski-standalone-oauth-token (token standalone do Antigravity) - jetski_candidates = [ - os.path.join(self.host_home, ".gemini", "jetski-standalone-oauth-token"), - os.path.join(self.host_home, ".config", "antigravity", "jetski-standalone-oauth-token"), - "/root/.gemini/jetski-standalone-oauth-token", - ] + self.extra_paths - - for p in jetski_candidates: - data = self._read_json(p) - if data: - tok_dict = data.get("token") if isinstance(data.get("token"), dict) else data - acc = tok_dict.get("access_token") or tok_dict.get("accessToken") - ref = tok_dict.get("refresh_token") or tok_dict.get("refreshToken") - if acc or ref: - return { - "source_path": p, - "accessToken": acc, - "refreshToken": ref, - "clientId": tok_dict.get("client_id") or data.get("client_id"), - "clientSecret": tok_dict.get("client_secret") or data.get("client_secret"), - "expiry": tok_dict.get("expiry"), - } - - # 2. oauth_creds.json - creds_candidates = [ - os.path.join(self.host_home, ".gemini", "oauth_creds.json"), - os.path.join(self.host_home, ".config", "antigravity", "oauth_creds.json"), - "/root/.gemini/oauth_creds.json", - ] - for p in creds_candidates: - data = self._read_json(p) - if data and (data.get("access_token") or data.get("refresh_token")): - return { - "source_path": p, - "accessToken": data.get("access_token"), - "refreshToken": data.get("refresh_token"), - "clientId": data.get("client_id") or data.get("clientId"), - "clientSecret": data.get("client_secret") or data.get("clientSecret"), - "expiry": data.get("expiry_date"), - } - - return None - - def discover_claude(self) -> Optional[Dict[str, Any]]: - """Descobre configurações e contas do Claude Code CLI e Anthropic.""" - settings_path = os.path.join(self.host_home, ".claude", "settings.json") - data = self._read_json(settings_path) - if data and isinstance(data.get("env"), dict): - env = data["env"] - api_key = env.get("ANTHROPIC_API_KEY") - if api_key: - return { - "source_path": settings_path, - "apiKey": api_key, - "baseUrl": env.get("ANTHROPIC_BASE_URL"), - } - - claude_json_path = os.path.join(self.host_home, ".claude.json") - data = self._read_json(claude_json_path) - if data: - oauth_acc = data.get("oauthAccount") if isinstance(data.get("oauthAccount"), dict) else None - return { - "source_path": claude_json_path, - "oauthAccount": oauth_acc, - "has_oauth": bool(oauth_acc), - "email": oauth_acc.get("emailAddress") if oauth_acc else None, - } - - cred_paths = [ - os.path.join(self.host_home, ".claude", "credentials.json"), - os.path.join(self.host_home, ".config", "claude", "credentials.json"), - ] - for p in cred_paths: - data = self._read_json(p) - if data and (data.get("apiKey") or data.get("token")): - return { - "source_path": p, - "apiKey": data.get("apiKey") or data.get("token"), - } - - return None - - def discover_github(self) -> Optional[Dict[str, Any]]: - """Descobre credenciais do GitHub CLI e Copilot.""" - copilot_hosts = os.path.join(self.host_home, ".config", "github-copilot", "hosts.json") - copilot_data = self._read_json(copilot_hosts) - if copilot_data: - for host, info in copilot_data.items(): - if isinstance(info, dict) and info.get("oauth_token"): - return { - "source_path": copilot_hosts, - "provider": "github", - "accessToken": info["oauth_token"], - "user": info.get("user"), - } - - gh_hosts = os.path.join(self.host_home, ".config", "gh", "hosts.yml") - if os.path.exists(gh_hosts): - try: - with open(gh_hosts, "r", encoding="utf-8") as f: - content = f.read() - m_token = re.search(r"oauth_token:\s*([^\s]+)", content) - m_user = re.search(r"user:\s*([^\s]+)", content) - if m_token: - return { - "source_path": gh_hosts, - "provider": "github", - "accessToken": m_token.group(1), - "user": m_user.group(1) if m_user else None, - } - except Exception: - pass - - return None - - def discover_codex_openai(self) -> Optional[Dict[str, Any]]: - """Descobre credenciais OpenAI e Codex.""" - codex_auth = os.path.join(self.host_home, ".codex", "auth.json") - data = self._read_json(codex_auth) - if data: - toks = data.get("tokens") if isinstance(data.get("tokens"), dict) else {} - api_key = data.get("OPENAI_API_KEY") - acc_tok = toks.get("access_token") - ref_tok = toks.get("refresh_token") - if api_key or acc_tok: - return { - "source_path": codex_auth, - "apiKey": api_key, - "accessToken": acc_tok, - "refreshToken": ref_tok, - "auth_mode": data.get("auth_mode"), - } - - candidates = [ - os.path.join(self.host_home, ".codex", "config.json"), - os.path.join(self.host_home, ".openai", "credentials"), - os.path.join(self.host_home, ".config", "openai", "credentials"), - ] - for p in candidates: - data = self._read_json(p) - if data: - return { - "source_path": p, - "apiKey": data.get("api_key") or data.get("apiKey") or data.get("token"), - "accessToken": data.get("access_token"), - "refreshToken": data.get("refresh_token"), - } - return None - - def discover_kiro(self) -> Optional[Dict[str, Any]]: - """Descobre credenciais AWS Kiro.""" - candidates = [ - os.path.join(self.host_home, ".kiro", "credentials"), - os.path.join(self.host_home, ".kiro", "settings", "auth.json"), - ] - for p in candidates: - data = self._read_json(p) - if data: - return { - "source_path": p, - "accessToken": data.get("accessToken") or data.get("token"), - "refreshToken": data.get("refreshToken"), - } - return None - - def discover_codeium(self) -> Optional[Dict[str, Any]]: - """Descobre configurações e chaves Codeium / Windsurf.""" - candidates = [ - os.path.join(self.host_home, ".codeium", "config.json"), - os.path.join(self.host_home, ".windsurf", "auth.json"), - ] - for p in candidates: - data = self._read_json(p) - if data: - return { - "source_path": p, - "apiKey": data.get("apiKey") or data.get("token"), - } - return None - - def discover_all(self) -> Dict[str, Any]: - """Varre todos os provedores suportados no host.""" - return { - "google": self.discover_google(), - "claude": self.discover_claude(), - "github": self.discover_github(), - "codex": self.discover_codex_openai(), - "kiro": self.discover_kiro(), - "codeium": self.discover_codeium(), - } - - def get_credential_for_provider(self, provider: str) -> Optional[Dict[str, Any]]: - """Busca credencial correspondente a um provedor do OmniRoute.""" - p_lower = provider.lower() - if p_lower in ("antigravity", "gemini-cli", "google"): - return self.discover_google() - if p_lower in ("claude", "anthropic"): - return self.discover_claude() - if p_lower in ("github", "copilot"): - return self.discover_github() - if p_lower in ("codex", "openai"): - return self.discover_codex_openai() - if p_lower in ("kiro", "aws-kiro"): - return self.discover_kiro() - if p_lower in ("codeium", "windsurf"): - return self.discover_codeium() - return None diff --git a/src/omini_rtksync/gateway.py b/src/omini_rtksync/gateway.py new file mode 100644 index 0000000..2a2a7c3 --- /dev/null +++ b/src/omini_rtksync/gateway.py @@ -0,0 +1,1277 @@ +"""Domínio: tudo o que este sincronizador sabe sobre o gateway a que se liga. + +Este é o ÚNICO módulo do pacote onde a divergência entre os três irmãos é +legítima e fica. Painel, render, sessão, SSO e i18n são o mesmo texto nos três; +o que muda é o que existe atrás deste arquivo — aqui um SQLite no disco do +gateway, num irmão um proxy com API HTTP e Postgres. + +Ele reúne o que antes morava em três módulos separados (`database.py`, +`discovery.py` e `providers.py`). Separados, eles eram três nomes de arquivo que +os irmãos não tinham, e enquanto os nomes fossem diferentes nenhum teste +conseguia afirmar que o resto era igual. + +Por que banco e não API: este gateway guarda conexões, chaves e catálogo num +SQLite próprio, e não publica API administrativa para escrevê-los. O painel lê +o arquivo diretamente e só escreve em coluna que o schema instalado tem. +""" + +import json +import os +import re +import sqlite3 +import time +import urllib.error +import urllib.parse +import urllib.request +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_connection, +) +from .identidade import NOME_DO_PRODUTO +from .models import ConnectionRecord + +# --------------------------------------------------------------------------- +# Banco do gateway: leitura e mutação segura do SQLite +# --------------------------------------------------------------------------- + +def get_db_connection(db_path: str) -> sqlite3.Connection: + if not os.path.exists(db_path): + raise FileNotFoundError(f"Banco SQLite do gateway não encontrado em: {db_path}") + conn = sqlite3.connect(db_path, timeout=15.0) + conn.row_factory = sqlite3.Row + return conn + + +def detect_connection_table(conn: sqlite3.Connection) -> str: + """Detecta se o gateway utiliza a tabela provider_connections ou providerConnections.""" + c = conn.cursor() + c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('provider_connections', 'providerConnections')") + row = c.fetchone() + if row: + return row[0] + return "provider_connections" + + +def _decode_json(value: Any) -> Optional[Dict[str, Any]]: + """Le uma coluna JSON tolerando texto vazio, NULL e dicionario ja decodificado.""" + if isinstance(value, dict): + return value + if not value: + return None + try: + decoded = json.loads(value) + except (TypeError, ValueError): + return None + return decoded if isinstance(decoded, dict) else None + + +def _anexar_saida_de_rede(conn: sqlite3.Connection, conexoes: List[Dict[str, Any]]) -> None: + """Resolve, por conexao, qual saida de rede o gateway usaria. + + O vinculo vive em ``proxy_assignments`` com ``scope='account'`` e + ``scope_id`` igual ao id da conexao; o proxy em si esta em + ``proxy_registry``. Tudo em uma consulta so, e em silencio quando a + instalacao e antiga demais para ter essas tabelas. + + Somente leitura: nada aqui escreve no banco. + """ + try: + existentes = { + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name IN ('proxy_assignments','proxy_registry')" + ) + } + if {"proxy_assignments", "proxy_registry"} - existentes: + return + + vinculos: Dict[str, str] = {} + for linha in conn.execute( + "SELECT a.scope_id, COALESCE(NULLIF(r.name,''), r.host || ':' || r.port) AS saida " + "FROM proxy_assignments a JOIN proxy_registry r ON r.id = a.proxy_id " + "WHERE a.scope = 'account' AND a.scope_id IS NOT NULL " + "ORDER BY a.position ASC" + ): + # position ASC e o primeiro vence: e a saida que a rotacao entrega + # quando o escopo tem um unico proxy vinculado. + vinculos.setdefault(str(linha[0]), str(linha[1])) + + for c in conexoes: + saida = vinculos.get(c["id"]) + if saida: + c["egressProxy"] = saida + except sqlite3.Error: + # Schema mais antigo ou banco em uso por outro processo: a coluna de + # saida simplesmente nao aparece, sem derrubar a listagem inteira. + return + + +def get_all_connections(db_path: str) -> List[Dict[str, Any]]: + """Carrega todas as conexões cadastradas no gateway.""" + conn = get_db_connection(db_path) + try: + tbl = detect_connection_table(conn) + cursor = conn.cursor() + cursor.execute(f"SELECT * FROM {tbl}") + rows = cursor.fetchall() + result = [] + for r in rows: + keys = r.keys() + item = dict(r) + + # Normalização de nomes de colunas relacionais + provider = item.get("provider", "") + name = item.get("name") or item.get("display_name") or provider + access_token = item.get("access_token") or item.get("accessToken") + refresh_token = item.get("refresh_token") or item.get("refreshToken") + api_key = item.get("api_key") or item.get("apiKey") + expires_at = item.get("expires_at") or item.get("expiresAt") + # Sem default: uma coluna vazia significa "ninguém testou", não + # "está saudável". Inventar "active" na leitura fazia o valor voltar + # ao banco no fim do ciclo, e o sincronizador passava a afirmar ao + # gateway uma saúde que nunca mediu — o mesmo defeito de marcar + # "invalid" o que não conseguiu ler, só que na direção oposta. + test_status = item.get("test_status") or item.get("testStatus") + + # Se houver campo JSON 'data' (o formato do gateway irmao), funde os campos + extra: Dict[str, Any] = {} + if "data" in keys and isinstance(item["data"], str): + try: + d = json.loads(item["data"]) + access_token = access_token or d.get("accessToken") + refresh_token = refresh_token or d.get("refreshToken") + api_key = api_key or d.get("apiKey") + expires_at = expires_at or d.get("expiresAt") + test_status = test_status or d.get("testStatus") + if isinstance(d, dict): + extra = d + except Exception: + pass + + # provider_specific_data e onde o gateway guarda baseUrl e afins. + specific = _decode_json(item.get("provider_specific_data")) or _decode_json( + extra.get("providerSpecificData") + ) + + result.append({ + "id": str(item["id"]), + "provider": provider, + "name": name, + "accessToken": access_token, + "refreshToken": refresh_token, + "apiKey": api_key, + "expiresAt": expires_at, + "testStatus": test_status, + "isOAuth": bool(access_token or refresh_token), + "hasApiKey": bool(api_key), + # Campos de saude, projetados um a um. A linha crua do banco NAO + # e devolvida: ela carrega access_token, refresh_token e api_key, + # e qualquer consumidor que a serializasse por engano publicaria + # as tres coisas de uma vez. + "providerSpecificData": specific, + "baseUrl": (specific or {}).get("baseUrl") or (specific or {}).get("baseURL"), + "discoveredModels": (specific or {}).get("discoveredModels") + or extra.get("discoveredModels") + or [], + "credentialState": (specific or {}).get("credentialState") + or extra.get("credentialState"), + "lastTested": item.get("last_tested") or extra.get("lastTested"), + # Renovar e verificar são eventos diferentes. Sem projetar este + # campo, "última renovação" no painel caía para o horário da + # última verificação e um token parado há dias parecia recém + # renovado a cada ciclo. + "lastRefreshAt": (specific or {}).get("lastRefreshAt") or extra.get("lastRefreshAt"), + "lastHealthCheckAt": item.get("last_health_check_at"), + "rateLimitedUntil": item.get("rate_limited_until") or extra.get("rateLimitedUntil"), + "lastError": item.get("last_error"), + "updatedAt": item.get("updated_at") or item.get("updatedAt"), + # Saida de rede: interruptores por conexao do proprio gateway. + # O vinculo em si vem de proxy_assignments, resolvido abaixo. + "proxyEnabled": bool(item.get("proxy_enabled")), + "perKeyProxyEnabled": bool(item.get("per_key_proxy_enabled")), + "egressProxy": None, + }) + + _anexar_saida_de_rede(conn, result) + return result + finally: + conn.close() + + +def to_iso_utc(epoch_ms: int) -> str: + """Converte epoch em milissegundos para o ISO-8601 em UTC que o gateway grava nativamente.""" + return ( + datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +def normalize_expiry_format(db_path: str, connection_id: str, expires_at_ms: int) -> bool: + """Regrava expires_at em ISO-8601 sem tocar nos tokens. + + A cura de formato acontecia so junto de uma renovacao bem-sucedida. Quando a + renovacao falha -- refresh token revogado, client_id ausente -- o epoch + numerico gravado como texto permanecia, e e justamente ele que o gateway + le com `new Date(...)` e obtem Invalid Date, desligando a propria renovacao + preventiva. O formato e curado de qualquer jeito. + """ + conn = get_db_connection(db_path) + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + try: + tbl = detect_connection_table(conn) + cursor = conn.cursor() + cursor.execute( + f"UPDATE {tbl} SET expires_at = ?, updated_at = ? WHERE id = ?", + (to_iso_utc(expires_at_ms), now_iso, connection_id), + ) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error: + return False + finally: + conn.close() + +def update_connection( + db_path: str, connection_id: str, access_token: str, refresh_token: str, expires_at_ms: int +) -> bool: + """Atualiza as credenciais normalizadas na tabela detectada.""" + conn = get_db_connection(db_path) + tbl = detect_connection_table(conn) + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + try: + cursor = conn.cursor() + cursor.execute(f"PRAGMA table_info({tbl})") + cols = [c["name"] for c in cursor.fetchall()] + + if "access_token" in cols: + # Tabela relacional do gateway (provider_connections). + # + # expires_at é uma coluna TEXT e o gateway a lê com `new Date(...)` + # (src/lib/tokenHealthCheck.ts). Um epoch numérico gravado como texto + # vira Invalid Date -> NaN -> o health check conclui que a conexão não + # tem expiração conhecida e nunca renova o token preventivamente. + # Por isso gravamos ISO-8601, o mesmo formato nativo do gateway. + # + # test_status precisa ser 'active': é o único valor que o gateway + # trata como saudável (src/sse/services/auth.ts::clearAccountError e + # tokenHealthCheck.ts). 'ok' não é reconhecido e faz a conexão parecer + # estar em estado de erro. + # O horário da renovação vive no JSON de provider_specific_data: + # o schema relacional não tem coluna para ele, e sem esse carimbo o + # painel não distingue "renovado agora" de "apenas verificado". + especifico = {} + if "provider_specific_data" in cols: + cursor.execute( + f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,) + ) + linha = cursor.fetchone() + if linha: + especifico = _decode_json(linha["provider_specific_data"]) or {} + especifico["lastRefreshAt"] = now_iso + cursor.execute( + f""" + UPDATE {tbl} + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', + provider_specific_data = ?, updated_at = ? + WHERE id = ? + """, + ( + access_token, + refresh_token, + to_iso_utc(expires_at_ms), + json.dumps(especifico), + now_iso, + connection_id, + ), + ) + else: + cursor.execute( + f""" + UPDATE {tbl} + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? + WHERE id = ? + """, + (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), + ) + elif "data" in cols: + # Formato compatível com JSON + cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) + row = cursor.fetchone() + d = {} + if row and row["data"]: + try: + d = json.loads(row["data"]) + except Exception: + pass + d["accessToken"] = access_token + if refresh_token: + d["refreshToken"] = refresh_token + d["expiresAt"] = expires_at_ms + # 'active' é o valor que dispara o reset de estado de saúde no + # o gateway irmao (resetHealthStateOnActivation em connectionsRepo.js); + # 'ok' só é reconhecido pela UI e não limpa travas de erro. + d["testStatus"] = "active" + d["lastRefreshAt"] = now_iso + cursor.execute( + f"UPDATE {tbl} SET data = ?, updatedAt = ? WHERE id = ?", + (json.dumps(d), now_iso, connection_id), + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def update_connection_health( + db_path: str, + connection_id: str, + *, + test_status: Optional[str] = None, + credential_state: Optional[str] = None, + discovered_models: Optional[List[str]] = None, + last_error: Optional[str] = None, + clear_rate_limit: bool = False, +) -> bool: + """Grava o resultado de uma sondagem, sem tocar em token nenhum. + + Antes disto o provider devolvia `testStatus`, `credentialState` e o catalogo + descoberto, e o motor jogava tudo fora: o painel recarregava a linha antiga e + nunca mostrava que uma chave havia sido recusada nem que a instancia local + estava fora do ar. + + Só escreve em coluna que existe no banco — o schema do gateway evolui entre + versões, e uma instalação mais antiga não pode quebrar por causa disso. + """ + conn = get_db_connection(db_path) + try: + tbl = detect_connection_table(conn) + cursor = conn.cursor() + cols = {c[1] for c in cursor.execute(f"PRAGMA table_info({tbl})")} + now_iso = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + campos: List[str] = [] + valores: List[Any] = [] + + if test_status and "test_status" in cols: + campos.append("test_status = ?") + valores.append(test_status) + if "last_tested" in cols: + campos.append("last_tested = ?") + valores.append(now_iso) + if "last_health_check_at" in cols: + campos.append("last_health_check_at = ?") + valores.append(now_iso) + if last_error is not None and "last_error" in cols: + campos.append("last_error = ?") + valores.append(last_error or None) + if clear_rate_limit and "rate_limited_until" in cols: + campos.append("rate_limited_until = ?") + valores.append(None) + + # credentialState e o catalogo local nao tem coluna propria: vao para o + # JSON de provider_specific_data, preservando o que ja estava la. + if (credential_state or discovered_models is not None) and "provider_specific_data" in cols: + cursor.execute(f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,)) + linha = cursor.fetchone() + atual = _decode_json(linha["provider_specific_data"]) if linha else None + atual = dict(atual or {}) + if credential_state: + atual["credentialState"] = credential_state + atual["credentialCheckedAt"] = now_iso + if discovered_models is not None: + atual["discoveredModels"] = discovered_models + campos.append("provider_specific_data = ?") + valores.append(json.dumps(atual)) + + # Schema de coluna JSON única (o formato que o gateway irmao usa e que + # este aceita em instalações migradas). Sem este ramo a sondagem + # era descartada inteira nessas bases: não há `test_status` nem + # `provider_specific_data` para receber os campos acima, e a função + # saía por `if not campos` como se não houvesse nada a gravar. + if "data" in cols and "test_status" not in cols: + cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) + linha = cursor.fetchone() + d: Dict[str, Any] = {} + if linha and linha["data"]: + try: + carregado = json.loads(linha["data"]) + if isinstance(carregado, dict): + d = carregado + except Exception: + # Coluna corrompida ou em formato inesperado: seguimos com o + # dicionário vazio e regravamos a linha com os campos de + # saúde. Abortar aqui faria uma linha ilegível bloquear para + # sempre a gravação da sondagem — justamente na conexão que + # mais precisa ser diagnosticada. + pass + if test_status: + d["testStatus"] = test_status + if credential_state: + d["credentialState"] = credential_state + d["credentialCheckedAt"] = now_iso + if discovered_models is not None: + d["discoveredModels"] = discovered_models + if last_error is not None: + d["lastError"] = last_error or None + if clear_rate_limit: + d.pop("rateLimitedUntil", None) + d["lastTested"] = now_iso + campos.append("data = ?") + valores.append(json.dumps(d)) + + if "updated_at" in cols: + campos.append("updated_at = ?") + valores.append(now_iso) + elif "updatedAt" in cols: + campos.append("updatedAt = ?") + valores.append(now_iso) + + if not campos: + return False + + valores.append(connection_id) + cursor.execute(f"UPDATE {tbl} SET {', '.join(campos)} WHERE id = ?", valores) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +# Colunas de ``api_keys`` que o painel tem permissao de ler. +# +# A lista existe para ser uma lista: ``SELECT *`` nesta tabela traria ``key`` +# (o segredo em claro), ``key_hash`` e ``key_prefix``. O prefixo tambem e +# segredo -- e um pedaco do proprio token -- e por isso NAO esta aqui. Quem +# identifica a chave na tela e o ``name``; sem nome, o ``id`` (um UUID, que nao +# abre porta nenhuma). +COLUNAS_SEGURAS_DE_CHAVE = ( + "id", + "name", + "created_at", + "expires_at", + "revoked_at", + "last_used_at", + "is_active", + "is_banned", + "model_access_mode", + "allowed_models", + "allowed_combos", + "scopes", + "max_requests_per_day", + "max_requests_per_minute", +) + +# Namespace do ``key_value`` onde o gateway guarda o catalogo sincronizado. +NAMESPACE_MODELOS = "syncedAvailableModels" + + +def _tabela_existe(conn: sqlite3.Connection, nome: str) -> bool: + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", (nome,) + ) + return cursor.fetchone() is not None + + +def _lista_json(valor: Any) -> List[Any]: + """Le uma coluna JSON que deveria ser um array, tolerando lixo e NULL.""" + if isinstance(valor, list): + return valor + if not valor: + return [] + try: + decodificado = json.loads(valor) + except (TypeError, ValueError): + return [] + return decodificado if isinstance(decodificado, list) else [] + + +def get_all_api_keys(db_path: str) -> List[Dict[str, Any]]: + """Chaves virtuais emitidas pelo gateway (tabela ``api_keys``). + + Todo gateway da familia emite chave virtual -- e o token que o cliente + apresenta no lugar da credencial do provedor -- e o painel precisa mostrar + quais existem, ate quando valem e se ainda estao aceitas. + + Somente leitura, e somente das colunas de COLUNAS_SEGURAS_DE_CHAVE: o + material do token nunca sai do banco. + + Instalacao antiga sem a tabela devolve lista vazia, do mesmo jeito que + ``get_all_combos`` faz com ``combos``: o cartao aparece com o estado vazio + em vez de derrubar a pagina inteira. + """ + conn = get_db_connection(db_path) + try: + if not _tabela_existe(conn, "api_keys"): + return [] + + # So pede o que a instalacao realmente tem: o schema do gateway cresce + # entre versoes, e uma coluna ausente faria a consulta inteira falhar. + presentes = {linha[1] for linha in conn.execute("PRAGMA table_info(api_keys)")} + colunas = [c for c in COLUNAS_SEGURAS_DE_CHAVE if c in presentes] + if "id" not in colunas: + return [] + + resultado: List[Dict[str, Any]] = [] + for linha in conn.execute(f"SELECT {', '.join(colunas)} FROM api_keys"): + item = dict(linha) + resultado.append({ + "id": str(item.get("id") or ""), + "name": item.get("name") or "", + "createdAt": item.get("created_at"), + "expiresAt": item.get("expires_at"), + "revokedAt": item.get("revoked_at"), + "lastUsedAt": item.get("last_used_at"), + # Colunas ausentes viram o padrao do proprio gateway: chave + # ativa e nao banida. Assumir o contrario pintaria de vermelho + # toda chave de uma instalacao antiga. + "isActive": bool(item["is_active"]) if item.get("is_active") is not None else True, + "isBanned": bool(item.get("is_banned")), + "modelAccessMode": item.get("model_access_mode") or "all", + "allowedModels": _lista_json(item.get("allowed_models")), + "allowedCombos": _lista_json(item.get("allowed_combos")), + "scopes": _lista_json(item.get("scopes")), + "maxRequestsPerDay": item.get("max_requests_per_day"), + "maxRequestsPerMinute": item.get("max_requests_per_minute"), + }) + resultado.sort(key=lambda k: (k["name"] or k["id"]).lower()) + return resultado + finally: + conn.close() + + +def get_all_registered_models(db_path: str) -> List[Dict[str, Any]]: + """Modelos que o gateway conhece, um por linha, com a conexao que os serve. + + Este gateway NAO tem tabela de modelos. O catalogo que ele publica em + ``/v1/models`` e montado em tempo de requisicao a partir de um registro + estatico somado ao que cada conexao sincronizou -- e essa segunda metade, a + unica que descreve esta instalacao, mora em ``key_value``, no namespace + ``syncedAvailableModels``, com a chave no formato ``:`` e um array JSON por valor. + + E dai que se le, e nao do ``/v1/models``: a rota HTTP exige uma chave de API + do proprio gateway, que o sincronizador nao tem e nao deveria passar a ter + so para desenhar uma tabela. O banco ja esta aberto aqui. + """ + conn = get_db_connection(db_path) + try: + if not _tabela_existe(conn, "key_value"): + return [] + + resultado: List[Dict[str, Any]] = [] + for chave, valor in conn.execute( + "SELECT key, value FROM key_value WHERE namespace = ?", (NAMESPACE_MODELOS,) + ): + # ``:`` -- o id e um UUID com hifens, nunca + # com dois-pontos, entao o primeiro separador e o unico. + provider, _, connection_id = str(chave).partition(":") + for entrada in _lista_json(valor): + if not isinstance(entrada, dict): + continue + identificador = entrada.get("id") + if not identificador: + continue + resultado.append({ + "id": str(identificador), + "name": entrada.get("name") or str(identificador), + "provider": provider, + "connectionId": connection_id, + "source": entrada.get("source") or "", + "description": entrada.get("description") or "", + "inputTokenLimit": entrada.get("inputTokenLimit"), + "outputTokenLimit": entrada.get("outputTokenLimit"), + "supportedEndpoints": [ + str(e) for e in _lista_json(entrada.get("supportedEndpoints")) + ], + }) + + resultado.sort(key=lambda m: (m["provider"].lower(), m["id"].lower())) + return resultado + except sqlite3.Error: + # ``key_value`` existe mas esta em uso ou em formato inesperado: o cartao + # cai para o estado vazio em vez de levar o painel junto. + return [] + finally: + conn.close() + + +def get_all_combos(db_path: str) -> List[Dict[str, Any]]: + """Carrega combos cadastrados no gateway se a tabela existir.""" + conn = get_db_connection(db_path) + try: + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='combos'") + if not cursor.fetchone(): + return [] + cursor.execute("SELECT * FROM combos") + rows = cursor.fetchall() + result = [] + for r in rows: + item = dict(r) + models_raw = item.get("models", "[]") + try: + models = json.loads(models_raw) if isinstance(models_raw, str) else models_raw + except Exception: + models = [] + result.append({ + "id": str(item.get("id")), + "name": item.get("name"), + "kind": item.get("kind", "llm"), + "models": models, + }) + return result + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Descoberta de credenciais locais no host +# --------------------------------------------------------------------------- + +class HostDiscoveryEngine: + """ + Localiza e extrai credenciais de ferramentas e CLIs instaladas no host. + Funciona tanto executando nativamente no host quanto dentro do container + com o diretório montado em HOST_HOME (ex: /root/host). + """ + + def __init__(self, host_home: Optional[str] = None, extra_paths: Optional[List[str]] = None): + self.host_home = self._resolve_host_home(host_home) + self.extra_paths = extra_paths or [] + + @staticmethod + def _resolve_host_home(override: Optional[str] = None) -> str: + if override and os.path.exists(override): + return override + env_host = os.environ.get("HOST_HOME") + if env_host and os.path.exists(env_host): + return env_host + if os.path.exists("/root/host") and os.path.isdir("/root/host"): + return "/root/host" + if os.path.exists("/host") and os.path.isdir("/host"): + return "/host" + return os.path.expanduser("~") + + def _read_json(self, path: str) -> Optional[Dict[str, Any]]: + if not os.path.exists(path) or not os.path.isfile(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except Exception: + return None + + def discover_google(self) -> Optional[Dict[str, Any]]: + """Descobre tokens do Google Antigravity / Gemini CLI.""" + # 1. jetski-standalone-oauth-token (token standalone do Antigravity) + jetski_candidates = [ + os.path.join(self.host_home, ".gemini", "jetski-standalone-oauth-token"), + os.path.join(self.host_home, ".config", "antigravity", "jetski-standalone-oauth-token"), + "/root/.gemini/jetski-standalone-oauth-token", + ] + self.extra_paths + + for p in jetski_candidates: + data = self._read_json(p) + if data: + tok_dict = data.get("token") if isinstance(data.get("token"), dict) else data + acc = tok_dict.get("access_token") or tok_dict.get("accessToken") + ref = tok_dict.get("refresh_token") or tok_dict.get("refreshToken") + if acc or ref: + return { + "source_path": p, + "accessToken": acc, + "refreshToken": ref, + "clientId": tok_dict.get("client_id") or data.get("client_id"), + "clientSecret": tok_dict.get("client_secret") or data.get("client_secret"), + "expiry": tok_dict.get("expiry"), + } + + # 2. oauth_creds.json + creds_candidates = [ + os.path.join(self.host_home, ".gemini", "oauth_creds.json"), + os.path.join(self.host_home, ".config", "antigravity", "oauth_creds.json"), + "/root/.gemini/oauth_creds.json", + ] + for p in creds_candidates: + data = self._read_json(p) + if data and (data.get("access_token") or data.get("refresh_token")): + return { + "source_path": p, + "accessToken": data.get("access_token"), + "refreshToken": data.get("refresh_token"), + "clientId": data.get("client_id") or data.get("clientId"), + "clientSecret": data.get("client_secret") or data.get("clientSecret"), + "expiry": data.get("expiry_date"), + } + + return None + + def discover_claude(self) -> Optional[Dict[str, Any]]: + """Descobre configurações e contas do Claude Code CLI e Anthropic.""" + settings_path = os.path.join(self.host_home, ".claude", "settings.json") + data = self._read_json(settings_path) + if data and isinstance(data.get("env"), dict): + env = data["env"] + api_key = env.get("ANTHROPIC_API_KEY") + if api_key: + return { + "source_path": settings_path, + "apiKey": api_key, + "baseUrl": env.get("ANTHROPIC_BASE_URL"), + } + + claude_json_path = os.path.join(self.host_home, ".claude.json") + data = self._read_json(claude_json_path) + if data: + oauth_acc = data.get("oauthAccount") if isinstance(data.get("oauthAccount"), dict) else None + return { + "source_path": claude_json_path, + "oauthAccount": oauth_acc, + "has_oauth": bool(oauth_acc), + "email": oauth_acc.get("emailAddress") if oauth_acc else None, + } + + cred_paths = [ + os.path.join(self.host_home, ".claude", "credentials.json"), + os.path.join(self.host_home, ".config", "claude", "credentials.json"), + ] + for p in cred_paths: + data = self._read_json(p) + if data and (data.get("apiKey") or data.get("token")): + return { + "source_path": p, + "apiKey": data.get("apiKey") or data.get("token"), + } + + return None + + def discover_github(self) -> Optional[Dict[str, Any]]: + """Descobre credenciais do GitHub CLI e Copilot.""" + copilot_hosts = os.path.join(self.host_home, ".config", "github-copilot", "hosts.json") + copilot_data = self._read_json(copilot_hosts) + if copilot_data: + for host, info in copilot_data.items(): + if isinstance(info, dict) and info.get("oauth_token"): + return { + "source_path": copilot_hosts, + "provider": "github", + "accessToken": info["oauth_token"], + "user": info.get("user"), + } + + gh_hosts = os.path.join(self.host_home, ".config", "gh", "hosts.yml") + if os.path.exists(gh_hosts): + try: + with open(gh_hosts, "r", encoding="utf-8") as f: + content = f.read() + m_token = re.search(r"oauth_token:\s*([^\s]+)", content) + m_user = re.search(r"user:\s*([^\s]+)", content) + if m_token: + return { + "source_path": gh_hosts, + "provider": "github", + "accessToken": m_token.group(1), + "user": m_user.group(1) if m_user else None, + } + except Exception: + pass + + return None + + def discover_codex_openai(self) -> Optional[Dict[str, Any]]: + """Descobre credenciais OpenAI e Codex.""" + codex_auth = os.path.join(self.host_home, ".codex", "auth.json") + data = self._read_json(codex_auth) + if data: + toks = data.get("tokens") if isinstance(data.get("tokens"), dict) else {} + api_key = data.get("OPENAI_API_KEY") + acc_tok = toks.get("access_token") + ref_tok = toks.get("refresh_token") + if api_key or acc_tok: + return { + "source_path": codex_auth, + "apiKey": api_key, + "accessToken": acc_tok, + "refreshToken": ref_tok, + "auth_mode": data.get("auth_mode"), + } + + candidates = [ + os.path.join(self.host_home, ".codex", "config.json"), + os.path.join(self.host_home, ".openai", "credentials"), + os.path.join(self.host_home, ".config", "openai", "credentials"), + ] + for p in candidates: + data = self._read_json(p) + if data: + return { + "source_path": p, + "apiKey": data.get("api_key") or data.get("apiKey") or data.get("token"), + "accessToken": data.get("access_token"), + "refreshToken": data.get("refresh_token"), + } + return None + + def discover_kiro(self) -> Optional[Dict[str, Any]]: + """Descobre credenciais AWS Kiro.""" + candidates = [ + os.path.join(self.host_home, ".kiro", "credentials"), + os.path.join(self.host_home, ".kiro", "settings", "auth.json"), + ] + for p in candidates: + data = self._read_json(p) + if data: + return { + "source_path": p, + "accessToken": data.get("accessToken") or data.get("token"), + "refreshToken": data.get("refreshToken"), + } + return None + + def discover_codeium(self) -> Optional[Dict[str, Any]]: + """Descobre configurações e chaves Codeium / Windsurf.""" + candidates = [ + os.path.join(self.host_home, ".codeium", "config.json"), + os.path.join(self.host_home, ".windsurf", "auth.json"), + ] + for p in candidates: + data = self._read_json(p) + if data: + return { + "source_path": p, + "apiKey": data.get("apiKey") or data.get("token"), + } + return None + + def discover_all(self) -> Dict[str, Any]: + """Varre todos os provedores suportados no host.""" + return { + "google": self.discover_google(), + "claude": self.discover_claude(), + "github": self.discover_github(), + "codex": self.discover_codex_openai(), + "kiro": self.discover_kiro(), + "codeium": self.discover_codeium(), + } + + def get_credential_for_provider(self, provider: str) -> Optional[Dict[str, Any]]: + """Busca credencial correspondente a um provedor do gateway.""" + p_lower = provider.lower() + if p_lower in ("antigravity", "gemini-cli", "google"): + return self.discover_google() + if p_lower in ("claude", "anthropic"): + return self.discover_claude() + if p_lower in ("github", "copilot"): + return self.discover_github() + if p_lower in ("codex", "openai"): + return self.discover_codex_openai() + if p_lower in ("kiro", "aws-kiro"): + return self.discover_kiro() + if p_lower in ("codeium", "windsurf"): + return self.discover_codeium() + return None + + +# --------------------------------------------------------------------------- +# Provedores: renovação, sondagem e saneamento por tipo de conexão +# --------------------------------------------------------------------------- + +class GoogleProvider: + """Renovador OAuth para contas Google (Antigravity / Gemini CLI) no gateway.""" + + OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" + + def __init__(self, credential_paths: Optional[List[str]] = None, discovery: Optional[Any] = None): + self.credential_paths = credential_paths or [] + self.discovery = discovery + + def find_local_credential_file(self) -> Optional[str]: + if self.discovery: + disc = self.discovery.discover_google() + if disc and disc.get("source_path"): + return disc["source_path"] + for p in self.credential_paths: + if p and os.path.exists(p) and os.path.isfile(p): + return p + return None + + def read_local_credential(self) -> Optional[Dict[str, Any]]: + if self.discovery: + disc = self.discovery.discover_google() + if disc and disc.get("accessToken"): + return { + "access_token": disc.get("accessToken"), + "refresh_token": disc.get("refreshToken"), + "client_id": disc.get("clientId"), + "client_secret": disc.get("clientSecret"), + "expiry": disc.get("expiry"), + } + path = self.find_local_credential_file() + if not path: + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + tok = data.get("access_token") or data.get("accessToken") or data.get("token") + if tok and "access_token" not in data: + data["access_token"] = tok + return data if isinstance(data, dict) else None + except Exception: + return None + + def refresh( + self, refresh_token: str, client_id: str, client_secret: str + ) -> Tuple[bool, Optional[Dict[str, Any]], str]: + if not client_id or not client_secret: + return False, None, "client_id ou client_secret não configurado no ambiente nem encontrado em shared.js" + payload = urllib.parse.urlencode({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + }).encode("utf-8") + + req = urllib.request.Request( + self.OAUTH_TOKEN_URL, + data=payload, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": f"{NOME_DO_PRODUTO}/1.0", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=20.0) as resp: + data = json.loads(resp.read().decode("utf-8")) + return True, data, "OK" + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace")[:300] + return False, None, f"HTTP {e.code}: {err_body}" + except Exception as e: + return False, None, str(e) + + +class GenericOAuthProvider: + """Monitor e sincronizador OAuth genérico do gateway (Claude, GitHub, Codex, Kiro).""" + + KNOWN_TOKEN_URLS = { + "claude": "https://api.anthropic.com/v1/oauth/token", + "github": "https://github.com/login/oauth/access_token", + "kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", + "codex": "https://auth.openai.com/oauth/token", + "kimi": "https://api.moonshot.cn/v1/oauth/token", + } + + def __init__(self, discovery: Optional[Any] = None): + self.discovery = discovery + + def can_handle(self, conn: Dict[str, Any]) -> bool: + provider = conn.get("provider", "").lower() + return bool(conn.get("isOAuth")) and provider not in ("antigravity", "gemini-cli") + + def check_and_refresh( + self, conn: Dict[str, Any], margin_seconds: int = 900 + ) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: + messages = [] + now_ms = int(time.time() * 1000) + provider = conn.get("provider", "") + + # 1. Verifica se há credencial local descoberta no host + if self.discovery: + local = self.discovery.get_credential_for_provider(provider) + if local and local.get("accessToken") and local.get("accessToken") != conn.get("accessToken"): + exp_ms = now_ms + (3599 * 1000) + res = { + "accessToken": local["accessToken"], + "refreshToken": local.get("refreshToken") or conn.get("refreshToken"), + "expiresAt": exp_ms, + } + src = local.get("source_path", "host") + messages.append(f"Token sincronizado a partir do host ({src})") + return True, res, messages + + # 2. Avaliação de expiração + from .normalizer import parse_expiry_to_ms + exp_ms = parse_expiry_to_ms(conn.get("expiresAt")) + if not exp_ms: + messages.append("Conexão OAuth sem registro temporal de expiração") + return False, None, messages + + rem = int((exp_ms - now_ms) / 1000) + if rem > margin_seconds: + messages.append(f"Token válido por mais {rem // 60} min ({rem}s)") + return False, None, messages + + # 3. Tentativa de refresh + refresh_token = conn.get("refreshToken") + token_url = self.KNOWN_TOKEN_URLS.get(provider.lower()) + client_id = os.environ.get(f"{provider.upper()}_CLIENT_ID") + client_secret = os.environ.get(f"{provider.upper()}_CLIENT_SECRET") + + if token_url and refresh_token and client_id: + try: + body = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + } + if client_secret: + body["client_secret"] = client_secret + payload = urllib.parse.urlencode(body).encode("utf-8") + req = urllib.request.Request( + token_url, + data=payload, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "User-Agent": f"{NOME_DO_PRODUTO}/1.0", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15.0) as resp: + data = json.loads(resp.read().decode("utf-8")) + new_tok = data.get("access_token") or data.get("accessToken") + if new_tok: + exp_in = int(data.get("expires_in", 3600)) + res = { + "accessToken": new_tok, + "refreshToken": data.get("refresh_token", refresh_token), + "expiresAt": now_ms + (exp_in * 1000), + } + messages.append(f"Token OAuth renovado com sucesso ({exp_in}s)") + return True, res, messages + except Exception as e: + messages.append(f"Refresh remoto retornou: {e}") + + messages.append(f"Token próximo da expiração ({rem}s restantes)") + return False, None, messages + + +class ApiKeyProvider: + """Gerenciador e sanitizador para conexões de API Key no gateway.""" + + def __init__( + self, + discovery: Optional[Any] = None, + validate_credentials: bool = False, + validation_timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Any] = None, + ): + self.discovery = discovery + # Desligado por padrão: montar o provider não pode gerar tráfego de saída. + self.validate_credentials = validate_credentials + self.validation_timeout = validation_timeout + self.opener = opener + + def can_handle(self, conn: Dict[str, Any]) -> bool: + # Uma instancia local carrega uma chave de fachada, entao `hasApiKey` + # sozinho tambem casaria com ela. Como o motor consulta este provider + # antes do LocalProvider, o catalogo local nunca seria descoberto -- e + # por isso que o Ollama local aparecia sem modelo nenhum no painel. + return bool(conn.get("hasApiKey")) and not LocalProvider.is_local_connection(conn) + + def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: + messages = [] + # `modified` decide se vale gravar; `renewed` decide se conta como + # renovacao no resumo do ciclo. Carimbar o horario de uma verificacao + # muda a linha, mas nao renovou credencial nenhuma -- e contar isso + # inflava o "N renovadas" do cron. + modified = False + renewed = False + res = dict(conn) + provider = conn.get("provider", "") + + # 1. Verifica se há chave de API mais recente no host + if self.discovery: + local = self.discovery.get_credential_for_provider(provider) + if local and local.get("apiKey") and local.get("apiKey") != conn.get("apiKey"): + res["apiKey"] = local["apiKey"] + modified = True + renewed = True + src = local.get("source_path", "host") + messages.append(f"Chave de API sincronizada a partir do host ({src})") + + # 2. Pergunta ao provedor se a chave ainda é aceita. Antes daqui a conexão + # era declarada "operacional e ativa" sem nenhuma verificação. + if self.validate_credentials: + record = ConnectionRecord.from_row(res) + result = check_connection( + record, timeout=self.validation_timeout, opener=self.opener + ) + res.update(result.to_dict()) + modified = True + + if result.state == STATE_VALID: + res["testStatus"] = "active" + # Um 4xx que nao seja 401/403 continua provando que a + # autenticacao passou -- a sonda manda corpo vazio de + # proposito, e o provedor so chega a reclamar do corpo depois + # de aceitar a chave. Dizer apenas "aceita (HTTP 400)" fazia a + # tela parecer errada; a frase agora explica o que o numero + # significa. + messages.append( + f"Autenticação aceita pelo provedor ({result.detail})" + if result.detail and "200" in str(result.detail) + else f"Autenticação aceita pelo provedor; a sondagem em si foi recusada ({result.detail})" + ) + elif result.state == STATE_INVALID: + res["testStatus"] = "invalid" + messages.append(f"Chave RECUSADA pelo provedor ({result.detail})") + elif result.state == STATE_RATE_LIMITED: + messages.append(f"Provedor aplicou rate limit na validação ({result.detail})") + elif result.state == STATE_UNREACHABLE: + messages.append(f"Chave não verificada: {result.detail}") + else: + messages.append(result.detail or "Credencial não verificável") + + if not messages: + messages.append("Chave de API inalterada") + + return renewed, res if modified else None, messages + + +# Catalog endpoints, in attempt order: Ollama-native and the OpenAI standard. +MODEL_CATALOG_PATHS = ("/api/tags", "/v1/models", "/models") +PROBE_TIMEOUT_SECONDS = 3.0 + +LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") +LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal") + + +class LocalProvider: + """Health monitor for local OpenAI-compatible instances (Ollama, vLLM, LM Studio).""" + + @staticmethod + def _base_url(conn: Dict[str, Any]) -> str: + especifico = conn.get("providerSpecificData") or {} + return str( + conn.get("baseUrl") + or especifico.get("baseUrl") + or especifico.get("baseURL") + or "" + ) + + @classmethod + def is_local_connection(cls, conn: Dict[str, Any]) -> bool: + """Classificacao de "local" compartilhada, para os providers nao brigarem.""" + provider = str(conn.get("provider", "")).lower() + endereco = cls._base_url(conn) + if endereco: + # Endereco declarado decide sozinho. O marcador "ollama" tambem casa + # com a conta hospedada em https://ollama.com/v1, e trata-la como + # local mandaria o sincronizador sondar um catalogo que nao existe + # ali, alem de tirar a conexao do caminho de validacao de chave. + return any(host in endereco for host in LOCAL_HOSTS) + if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): + return True + return False + + def can_handle(self, conn: Dict[str, Any]) -> bool: + if self.is_local_connection(conn): + return True + # Sem token e sem chave nao ha o que outro provider faca com a conexao. + return not (conn.get("isOAuth") or conn.get("hasApiKey")) + + def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], str]: + """Query the local instance catalog. Returns (models, error).""" + if not base_url: + return [], "baseUrl not declared on the connection" + + root = base_url.rstrip("/") + # An OpenAI-shaped baseUrl already ends in /v1; the root serves /api/tags. + origin = root[: -len("/v1")] if root.endswith("/v1") else root + last_error = "" + answered = False + + for path in MODEL_CATALOG_PATHS: + target = f"{origin}{path}" if path.startswith("/api") else f"{root}{path}" + try: + req = urllib.request.Request( + target, headers={"User-Agent": f"{NOME_DO_PRODUTO}-LocalProbe/1.0"} + ) + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT_SECONDS) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + # The host answered, this path just is not the right one — keep trying. + last_error = str(e) + continue + except (urllib.error.URLError, OSError) as e: + # Nothing is listening: trying the remaining paths only multiplies the + # timeout (3 endpoints x 3s) on every sweep. Give up now. + return [], str(e) + except ValueError as e: + last_error = str(e) + continue + + # Chegar aqui significa resposta HTTP valida e JSON parseavel: o + # servico esta de pe, tendo modelo ou nao. + answered = True + models = self._extract_model_names(payload) + if models: + return models, "" + + if answered: + return [], "" + return [], last_error or "no model returned by the local instance" + + @staticmethod + def _extract_model_names(payload: Any) -> List[str]: + """Extract model names from the Ollama (/api/tags) and OpenAI (/v1/models) shapes.""" + if not isinstance(payload, dict): + return [] + entries = payload.get("models") or payload.get("data") or [] + names = [] + for entry in entries: + if isinstance(entry, str): + names.append(entry) + elif isinstance(entry, dict): + name = entry.get("name") or entry.get("id") or entry.get("model") + if name: + names.append(str(name)) + return names + + def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: + """Sonda a instancia local. + + O primeiro elemento e a contagem de renovacao do ciclo: uma sondagem + nunca renova credencial, entao e sempre False. O dicionario devolvido e + o que deve ser gravado. + """ + models, probe_error = self.discover_models(self._base_url(conn), conn.get("apiKey") or "") + + if models: + return ( + False, + {"discoveredModels": models, "testStatus": "active"}, + [f"Local instance answered with {len(models)} model(s): {', '.join(models[:5])}"], + ) + + # Erro vazio significa que a instancia respondeu com catalogo vazio -- + # instalacao nova, sem modelo baixado. Esta no ar. + if not probe_error: + return ( + False, + {"discoveredModels": [], "testStatus": "active"}, + ["Local instance answered with an empty model catalog"], + ) + + # With no catalog response the connection is not assumed healthy: this is + # exactly the "the local Ollama went down and nobody noticed" case. + return ( + False, + {"testStatus": "unreachable", "lastError": probe_error}, + [f"Local instance did not answer the model catalog: {probe_error}"], + ) diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 577b510..425c350 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -1,14 +1,22 @@ -"""Internacionalização da interface do OminiRTKSync. +"""Internacionalização da interface do painel. 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. + +O catálogo é o mesmo texto nos três irmãos. O nome do produto e o do gateway +nunca são escritos aqui: entram por interpolação a partir de `identidade.py`, +que é o único arquivo onde eles moram. Uma chave que só um painel usa continua +declarada nos três -- uma tradução a mais não custa nada, e um catálogo que +diverge por omissão é como os três se separaram da primeira vez. """ from typing import Dict +from .identidade import ROTULOS_DO_PRODUTO, NOME_DO_GATEWAY + DEFAULT_LANGUAGE = "en" # Código do idioma -> (rótulo nativo, classe de bandeira do flag-icons) @@ -20,7 +28,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = { "en": { - "app.subtitle": "OmniRoute Universal Token & Connection Synchronizer", + "app.subtitle": f"{NOME_DO_GATEWAY} Universal Token & Connection Synchronizer", "app.gateway_unset": "gateway not configured", "action.refresh": "Refresh", "action.access": "Access", @@ -44,32 +52,71 @@ "previous one, so sign in again to continue.", "auth.updated_link": "Back to the dashboard", "auth.required": "Authentication required.", + "auth.login_intro": "Sign in to see the panel.", + "auth.user": "User", + "auth.password": "Password", + "auth.enter": "Sign in", + "auth.login_failed": "Wrong user or password.", + "auth.too_many": "Too many attempts", + "auth.too_many_body": "Wait {seconds}s before trying again.", + "auth.challenge": "Security verification", + "auth.challenge_prompt": "Select the {item} to confirm you are human:", + "auth.item_key": "Key", + "auth.item_shield": "Shield", + "auth.item_lock": "Lock", + "auth.item_star": "Star", + "auth.item_heart": "Heart", + "auth.item_bell": "Bell", + "auth.item_lightning": "Lightning", + "auth.item_gear": "Gear", + "auth.logout": "Sign out", "auth.required_body": "This dashboard is private. Sign in to continue.", "gateway.title": "Gateway connection", + "gateway.db_summary": "Operational ({connections} connections, {combos} combos)", + "gateway.db_missing": "Database not found", "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", + # Vocabulário do ciclo: o trabalho que este agendador faz muda com o + # gateway, e o texto acompanha. + "cron.title": "Scheduler", + "cron.run_now": "Run now", "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_findings": "Findings so far", "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)", + # Vocabulário do ciclo: o trabalho que este agendador faz muda com o + # gateway, e o texto acompanha. + "cron.result_line": "{inspected} · ({duration}ms)", + "cron.unavailable": "The scheduler is not available on this instance.", + "cron.failed": "The cycle could not be completed: {error}", "connections.title": "Monitored connections", "connections.empty": "No connection registered on the gateway.", + "connections.empty_hint": f"In {NOME_DO_GATEWAY} a connection is the upstream endpoint behind the " + "registered models; none of them declares one yet.", + "connections.lifecycle_note": "The upstream endpoint has no expiry of its own: what expires " + "is the credential at the provider, outside this gateway.", + "connections.served_models": "Models served", "table.provider": "Provider", "table.name": "Name", "table.type": "Type", "table.status": "Status", "table.remaining": "Time remaining", "table.diagnosis": "Renewal diagnosis", + "table.details": "Details", + "table.expires_at": "Expires at", + "table.rpm_limit": "RPM limit", + "table.tpm_limit": "TPM limit", + "table.max_budget": "Budget ceiling", + "table.api_base": "API base", "table.models": "models", "reason.local_ok": "Local instance answered with {count} model(s)", "reason.local_unreachable": "Local instance did not answer the model catalog", @@ -78,13 +125,56 @@ "egress.shared": "shares the gateway address with {count} accounts", "egress.single": "gateway address (only account)", "egress.unknown": "egress unknown", + "egress.title": "Network egress", "table.combo": "Combo", "table.cascade": "Model cascade", "combos.title": "Resilience combos", "combos.empty": "No fallback combo registered.", + "combos.empty_hint": f"In {NOME_DO_GATEWAY} the combo is the router fallback " + "(router_settings.fallbacks); none is declared.", + "combos.kind_context_window": "context window", + "combos.kind_content_policy": "content policy", + "keys.title": "Virtual keys", + "keys.empty": "No virtual key issued by the gateway.", + "table.alias": "Alias", + "table.team": "Team", + "table.spend": "Spend", + "keys.enabled": "Accepted", + "keys.disabled": "Deactivated", + "keys.access_all": "All models", + "keys.access_restricted": "Restricted to the listed models", + "keys.revoked": "Revoked", + "keys.banned": "Banned", + "models.title": "Registered models", + "models.empty": "The gateway answered with an empty model catalogue.", + "credential.env": "Kept in the proxy environment", + "credential.named": "Named credential", + "credential.inline": "Declared on the model", + "credential.absent": "Not exposed by the gateway", + "limits.title": "Limit coherence", + "limits.ok": "Every limit respects the level above it.", + "models.no_key": "The gateway issues no active key, and it only hands the model " + "catalogue to a key it issued itself.", + "models.unreachable": "The gateway did not answer the model catalogue.", + "models.inherited": "Status, validity and last renewal come from the connection that serves this model.", + "models.showing": "Showing {shown} of {total} models — open the gateway for the full list.", + "table.connection": "Connection", + "table.issued_at": "Issued at", + "table.last_used": "Last used", + "table.scopes": "Scopes", + "table.key_state": "Key state", + "table.model_access": "Model access", + "table.not_declared": "Not declared", + "table.source": "Origin", + "table.context_limit": "Input limit", + "table.output_limit": "Output limit", + "table.endpoints": "Endpoints", + "table.description": "Description", "type.oauth": "OAuth 2.0", "type.api_key": "API key", "type.local": "Local", + "type.virtual_key": "Virtual key", + "type.synced_model": "Synced model", "health.active": "Active", "health.expiring_soon": "Expiring", "health.expired": "Expired", @@ -94,6 +184,13 @@ "health.invalid": "Rejected", "health.unreachable": "Unreachable", "health.not_checked": "Not checked", + "health.blocked": "Blocked", + "health.over_budget": "Budget exhausted", + "health.valid": "Accepted", + "metric.virtual_keys": "Virtual keys", + "metric.teams": "Teams", + "metric.expiring": "Expiring / expired", + "metric.limit_findings": "Limit findings", "gateway.diagnostics": "Diagnostics", "gateway.diag_ok": "Gateway and SQLite database fully operational", "gateway.diag_db_failed": "Gateway online, database unreadable", @@ -104,11 +201,13 @@ "action.validate": "Validate credentials", "duration.unknown_expiry": "Expiry unknown", "duration.no_expiry": "No expiry (static key)", + "duration.no_expiry_short": "No expiry", "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.", + "action.cron_ran": "Cycle ran in {duration}ms: {inspected} inspected, {findings} findings.", "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.", @@ -123,18 +222,119 @@ "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.", + "auth.save_failed": "The new password could not be stored.", + "auth.env_managed": "Credentials for this panel are managed outside it. Change them where the service is configured, then restart it.", + "pagination.range": "{inicio}-{fim} of {total}", + "pagination.label": "Pagination", "footer.signed_in": "Signed in as", "footer.generated": "Data rendered on the server at", + "language.save_failed": "Could not save the language preference: the panel storage is not writable.", "language.label": "Language", + "action.settings": "Settings", + "sso.title": "Single sign-on (SSO)", + "sso.intro": "Optional. The local user and password form never leaves the screen, so a provider outage does not lock you out.", + "sso.tab_oidc": "OpenID Connect", + "sso.tab_saml": "SAML 2.0", + "sso.provider": "Active provider", + "sso.provider_none": "Off (password only)", + "sso.provider_oidc": "OpenID Connect", + "sso.provider_saml": "SAML 2.0 (unavailable)", + "sso.enabled_help": "One provider at a time. Two enabled at once is what makes a response " + "from one acceptable as if it came from the other.", + "sso.enabled_off": "Disabled (password only)", + "sso.enabled_oidc": "OIDC", + "sso.base_url": "Public panel address", + "sso.base_url_hint": "The exact origin the browser uses, with no trailing slash. It has " + "to be FIXED: a quick tunnel changes address on every start and " + "every return address registered at the provider stops matching, so " + "single sign-on needs a named tunnel or Tailscale.", + "sso.base_url_help": "Exact public origin, no trailing slash. The return address is built from this value and never from the request headers.", + "sso.redirect_uri": "Return address to register with the provider", + "sso.callback_url": "Redirect URI to register at the provider", + "sso.callback_help": "Copy this exact string into the provider. A single character of " + "difference and the provider refuses the exchange.", + "sso.issuer": "Issuer", + "sso.issuer_help": "The issuer published in the discovery document. It must match " + "character for character.", + "sso.client_id": "Client ID", + "sso.client_secret": "Client secret", + "sso.secret_stored": "A secret is stored. Leave the field blank to keep it.", + "sso.secret_absent": "No secret stored yet.", + "sso.secret_missing": "No secret stored yet. Without one, SSO stays off.", + "sso.secret_from_env": "The secret comes from the OIDC_CLIENT_SECRET environment variable. Change it there and restart the service.", + "sso.secret_keep_help": "It is never shown again. Leave this field empty to keep the " + "current one.", + "sso.secret_failed": "Could not store the client secret: the panel storage is not writable.", + "sso.scopes": "Scopes", + "sso.scopes_help": "Space separated. The default covers the e-mail address and the profile.", + "sso.allowed_domains": "Allowed domains", + "sso.allowed_emails": "Allowed e-mail addresses", + "sso.allowlist_hint": "Comma separated, and it cannot be empty: without it every account " + "at the provider would get in.", + "sso.allowlist_help": "Comma separated, and at least one of the two. An empty list means every account at the provider gets in, so the panel refuses to turn SSO on without it.", + "sso.local_password": "Your current panel password", + "sso.local_password_help": "Required here on top of the session: whoever steals an eight-hour session could otherwise point the panel at a hostile provider and add themselves to the allowed list.", + "sso.current_password": "Current panel password", + "sso.confirm_hint": "Saving asks for the local password again: whoever steals a session " + "must not be able to point the panel at a hostile provider and put " + "themselves on the list.", + "sso.current_password_help": "Required on top of the session: a stolen cookie must not be " + "enough to point the panel at a hostile provider.", + "sso.save": "Save SSO settings", + "sso.saved": "Single sign-on settings saved.", + "sso.turned_off": "Single sign-on is off. The local form keeps working.", + "sso.save_refused_password": "Wrong panel password: nothing was changed.", + "sso.save_refused_allowlist": "Add at least one domain or address: an empty list would " + "let every account at the provider in.", + "sso.save_refused_fields": "Fill in every field of the chosen provider, including the " + "public address of this panel.", + "sso.save_refused_secret": "The client secret could not be written to disk, so single " + "sign-on was not turned on.", + "sso.save_refused_saml": "SAML 2.0 is not available in this image.", + "sso.save_failed": "Could not save the settings.", + "sso.wrong_password": "Wrong panel password: nothing was changed.", + "sso.allowlist_required": "Fill in at least one allowed domain or e-mail address. SSO without an allowed list lets in every account at the provider.", + "sso.incomplete": "Fill in the public address, the issuer and the client ID before turning SSO on.", + "sso.no_secret": "No client secret: set one here or in the OIDC_CLIENT_SECRET environment variable.", + "sso.saml_refused": "SAML 2.0 cannot be turned on in this image.", + "sso.disabled_by_env": "Single sign-on is switched off by the SSO_DISABLED environment variable. The settings below are kept, but no SSO route answers.", + "sso.login_button": "Sign in with {provider}", + "sso.need_base_url": "The public panel address must be a scheme and a host, with no path, " + "and https outside the loopback.", + "sso.need_issuer": "The issuer is required and must use https outside the loopback.", + "sso.need_client_id": "The client ID is required.", + "sso.need_secret": "The client secret is required.", + "sso.need_allowlist": "Fill in at least one allowed domain or e-mail.", + "sso.tunnel_warning": "A quick tunnel gets a new address on every start, and every return " + "address registered at the provider stops matching. Single sign-on " + "needs a named tunnel or Tailscale, with a fixed address.", + "sso.sign_in_with": "Sign in with {provider}", + "sso.unavailable": "The identity provider did not answer. Sign in with your user and password.", + "sso.or": "or", + "sso.failed": "Could not sign in through the identity provider. Try again, or use your user and password.", + "sso.entering": "Signing in", + "sso.entering_body": "Single sign-on accepted. Opening the panel.", + "sso.status_on": "On · {provider}", + "sso.status_off": "Off", + "sso.signing_in": "Signing in", + "sso.signing_in_body": "The identity provider confirmed who you are. Taking you to the panel.", + "sso.logout_note": "Signing out clears the panel session only. The session at the identity provider stays open, so the next click on the SSO button may not ask for a password again.", + "sso.landing_title": "Signing in...", + "sso.landing_body": "The session has been created. Taking you to the dashboard.", + "sso.idp_entity_id": "Identity provider entity ID", + "sso.idp_sso_url": "Identity provider sign-on URL", + "sso.idp_cert": "Identity provider X.509 certificate", + "sso.metadata_hint": "Once saved, download the service description at {url} while signed " + "in and hand it to the identity provider.", + "sso.idp_cert_help": "Public certificate, safe to store next to the rest of the settings.", + "sso.saml_unavailable": "SAML 2.0 is not available in this image. It needs a library that signs and verifies XML, and doing it by hand would accept forged assertions in silence. The fields are here so the settings are ready when the image ships with it.", + "sso.saml_pending": "The SAML2 library is installed, but this version of the panel only " + "signs in through OIDC. SAML2 sign-in is the next phase.", }, "pt": { - "app.subtitle": "OmniRoute Universal Token & Connection Synchronizer", + "app.subtitle": f"{NOME_DO_GATEWAY} Universal Token & Connection Synchronizer", "app.gateway_unset": "gateway não configurado", "action.refresh": "Atualizar", "action.access": "Acesso", @@ -158,32 +358,71 @@ "então autentique-se de novo para continuar.", "auth.updated_link": "Voltar ao painel", "auth.required": "Autenticação requerida.", + "auth.login_intro": "Entre para ver o painel.", + "auth.user": "Usuário", + "auth.password": "Senha", + "auth.enter": "Entrar", + "auth.login_failed": "Usuário ou senha incorretos.", + "auth.too_many": "Tentativas demais", + "auth.too_many_body": "Aguarde {seconds}s antes de tentar de novo.", + "auth.challenge": "Verificação de segurança", + "auth.challenge_prompt": "Selecione o(a) {item} para confirmar que é humano:", + "auth.item_key": "Chave", + "auth.item_shield": "Escudo", + "auth.item_lock": "Cadeado", + "auth.item_star": "Estrela", + "auth.item_heart": "Coração", + "auth.item_bell": "Sino", + "auth.item_lightning": "Raio", + "auth.item_gear": "Engrenagem", + "auth.logout": "Sair", "auth.required_body": "Este painel é privado. Autentique-se para continuar.", "gateway.title": "Conexão com o gateway", + "gateway.db_summary": "Operacional ({connections} conexões, {combos} combos)", + "gateway.db_missing": "Banco não encontrado", "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", + # Vocabulário do ciclo: o trabalho que este agendador faz muda com o + # gateway, e o texto acompanha. + "cron.title": "Agendador", + "cron.run_now": "Executar agora", "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_findings": "Achados até agora", "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)", + # Vocabulário do ciclo: o trabalho que este agendador faz muda com o + # gateway, e o texto acompanha. + "cron.result_line": "{inspected} · ({duration}ms)", + "cron.unavailable": "O agendador não está disponível nesta instância.", + "cron.failed": "O ciclo não pôde ser concluído: {error}", "connections.title": "Conexões monitoradas", "connections.empty": "Nenhuma conexão registrada no gateway.", + "connections.empty_hint": f"No {NOME_DO_GATEWAY} a conexão é o destino por trás dos modelos " + "cadastrados; nenhum deles declara um destino ainda.", + "connections.lifecycle_note": "O destino não tem validade própria: o que expira é a " + "credencial no provedor, fora deste gateway.", + "connections.served_models": "Modelos servidos", "table.provider": "Provedor", "table.name": "Nome", "table.type": "Tipo", "table.status": "Status", "table.remaining": "Validade restante", "table.diagnosis": "Diagnóstico da renovação", + "table.details": "Detalhes", + "table.expires_at": "Expira em", + "table.rpm_limit": "Limite RPM", + "table.tpm_limit": "Limite TPM", + "table.max_budget": "Teto de orçamento", + "table.api_base": "Base da API", "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", @@ -192,13 +431,56 @@ "egress.shared": "divide o endereço do gateway com {count} contas", "egress.single": "endereço do gateway (única conta)", "egress.unknown": "saída desconhecida", + "egress.title": "Saída de rede", "table.combo": "Combo", "table.cascade": "Cascata de modelos", "combos.title": "Combos de resiliência", "combos.empty": "Nenhum combo de fallback registrado.", + "combos.empty_hint": f"No {NOME_DO_GATEWAY} o combo é o fallback do roteador " + "(router_settings.fallbacks); nenhum está declarado.", + "combos.kind_context_window": "janela de contexto", + "combos.kind_content_policy": "política de conteúdo", + "keys.title": "Chaves virtuais", + "keys.empty": "Nenhuma chave virtual emitida pelo gateway.", + "table.alias": "Apelido", + "table.team": "Time", + "table.spend": "Gasto", + "keys.enabled": "Aceita", + "keys.disabled": "Desativada", + "keys.access_all": "Todos os modelos", + "keys.access_restricted": "Restrita aos modelos listados", + "keys.revoked": "Revogada", + "keys.banned": "Banida", + "models.title": "Modelos cadastrados", + "models.empty": "O gateway respondeu com o catálogo de modelos vazio.", + "credential.env": "Mantida no ambiente do proxy", + "credential.named": "Credencial nomeada", + "credential.inline": "Declarada no modelo", + "credential.absent": "Não exposta pelo gateway", + "limits.title": "Coerência dos limites", + "limits.ok": "Todo limite respeita o nível acima.", + "models.no_key": "O gateway não tem nenhuma chave ativa emitida, e ele só entrega o " + "catálogo de modelos a uma chave que ele mesmo emitiu.", + "models.unreachable": "O gateway não respondeu ao catálogo de modelos.", + "models.inherited": "Status, validade e última renovação vêm da conexão que serve este modelo.", + "models.showing": "Mostrando {shown} de {total} modelos — a lista completa está no gateway.", + "table.connection": "Conexão", + "table.issued_at": "Emitida em", + "table.last_used": "Último uso", + "table.scopes": "Escopos", + "table.key_state": "Estado da chave", + "table.model_access": "Acesso a modelos", + "table.not_declared": "Não declarado", + "table.source": "Origem", + "table.context_limit": "Limite de entrada", + "table.output_limit": "Limite de saída", + "table.endpoints": "Endpoints", + "table.description": "Descrição", "type.oauth": "OAuth 2.0", "type.api_key": "Chave de API", "type.local": "Local", + "type.virtual_key": "Chave virtual", + "type.synced_model": "Modelo sincronizado", "health.active": "Ativo", "health.expiring_soon": "Expirando", "health.expired": "Expirado", @@ -208,6 +490,13 @@ "health.invalid": "Recusada", "health.unreachable": "Inacessível", "health.not_checked": "Não verificada", + "health.blocked": "Bloqueada", + "health.over_budget": "Orçamento esgotado", + "health.valid": "Aceita", + "metric.virtual_keys": "Chaves virtuais", + "metric.teams": "Times", + "metric.expiring": "Vencendo / vencidas", + "metric.limit_findings": "Incoerências de limite", "gateway.diagnostics": "Diagnóstico", "gateway.diag_ok": "Gateway e banco SQLite totalmente operacionais", "gateway.diag_db_failed": "Gateway online, banco ilegível", @@ -218,11 +507,13 @@ "action.validate": "Validar credenciais", "duration.unknown_expiry": "Validade desconhecida", "duration.no_expiry": "Sem expiração (chave estática)", + "duration.no_expiry_short": "Sem expiração", "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.", + "action.cron_ran": "Ciclo executado em {duration}ms: {inspected} inspecionados, {findings} achados.", "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.", @@ -237,18 +528,119 @@ "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.", + "auth.save_failed": "Não foi possível gravar a nova senha.", + "auth.env_managed": "As credenciais deste painel são gerenciadas fora dele. Altere-as onde o serviço é configurado e reinicie-o.", + "pagination.range": "{inicio}-{fim} de {total}", + "pagination.label": "Paginação", "footer.signed_in": "Autenticado como", "footer.generated": "Dados gerados no servidor em", + "language.save_failed": "Nao foi possivel gravar o idioma: o armazenamento do painel nao aceita escrita.", "language.label": "Idioma", + "action.settings": "Configurações", + "sso.title": "Entrada federada (SSO)", + "sso.intro": "Opcional. O formulário de usuário e senha nunca sai da tela, então o provedor cair não tranca ninguém do lado de fora.", + "sso.tab_oidc": "OpenID Connect", + "sso.tab_saml": "SAML 2.0", + "sso.provider": "Provedor ativo", + "sso.provider_none": "Desligado (só senha)", + "sso.provider_oidc": "OpenID Connect", + "sso.provider_saml": "SAML 2.0 (indisponível)", + "sso.enabled_help": "Um provedor por vez. Dois ligados ao mesmo tempo é o que faz a " + "resposta de um ser aceita como se fosse a do outro.", + "sso.enabled_off": "Desligado (somente senha)", + "sso.enabled_oidc": "OIDC", + "sso.base_url": "Endereço público do painel", + "sso.base_url_hint": "A origem exata que o navegador usa, sem barra no fim. Tem de ser " + "FIXA: um túnel rápido troca de endereço a cada subida e todo " + "endereço de retorno registrado no provedor deixa de bater, então o " + "acesso federado exige túnel nomeado ou Tailscale.", + "sso.base_url_help": "Origem pública exata, sem barra no fim. O endereço de retorno nasce deste valor, e nunca dos cabeçalhos da requisição.", + "sso.redirect_uri": "Endereço de retorno a registrar no provedor", + "sso.callback_url": "Endereço de retorno a cadastrar no provedor", + "sso.callback_help": "Copie exatamente esta linha para o provedor. Um caractere de " + "diferença e ele recusa a troca.", + "sso.issuer": "Emissor (issuer)", + "sso.issuer_help": "O emissor publicado no documento de descoberta. Ele tem de bater " + "caractere a caractere.", + "sso.client_id": "Identificador do cliente", + "sso.client_secret": "Segredo do cliente", + "sso.secret_stored": "Há um segredo guardado. Deixe o campo em branco para mantê-lo.", + "sso.secret_absent": "Ainda não há segredo guardado.", + "sso.secret_missing": "Nenhum segredo guardado ainda. Sem ele, o SSO fica desligado.", + "sso.secret_from_env": "O segredo vem da variável de ambiente OIDC_CLIENT_SECRET. Altere-o lá e reinicie o serviço.", + "sso.secret_keep_help": "Ele nunca é exibido de volta. Deixe este campo em branco para " + "manter o atual.", + "sso.secret_failed": "Não foi possível gravar o segredo do cliente: o armazenamento do " + "painel não aceita escrita.", + "sso.scopes": "Escopos", + "sso.scopes_help": "Separados por espaço. O padrão cobre o endereço de e-mail e o perfil.", + "sso.allowed_domains": "Domínios autorizados", + "sso.allowed_emails": "E-mails autorizados", + "sso.allowlist_hint": "Separados por vírgula, e a lista não pode ficar vazia: sem ela " + "toda conta do provedor entraria.", + "sso.allowlist_help": "Separados por vírgula, e ao menos um dos dois. Lista vazia significa que toda conta do provedor entra, então o painel recusa ligar o SSO sem ela.", + "sso.local_password": "Sua senha atual do painel", + "sso.local_password_help": "Exigida aqui além da sessão: quem sequestrar uma sessão de oito horas poderia, sem isso, apontar o painel para um provedor hostil e se colocar na lista de autorizados.", + "sso.current_password": "Senha atual do painel", + "sso.confirm_hint": "Salvar pede a senha local de novo: quem roubar uma sessão não pode " + "apontar o painel para um provedor hostil e se colocar na lista.", + "sso.current_password_help": "Exigida além da sessão: um cookie roubado não pode bastar " + "para apontar o painel a um provedor hostil.", + "sso.save": "Salvar configuração de SSO", + "sso.saved": "Configuração de entrada federada salva.", + "sso.turned_off": "Acesso federado desligado. O formulário local continua funcionando.", + "sso.save_refused_password": "Senha do painel incorreta: nada foi alterado.", + "sso.save_refused_allowlist": "Informe ao menos um domínio ou endereço: uma lista vazia " + "deixaria entrar toda conta do provedor.", + "sso.save_refused_fields": "Preencha todos os campos do provedor escolhido, inclusive o " + "endereço público deste painel.", + "sso.save_refused_secret": "Não foi possível gravar o segredo do cliente em disco, então " + "o acesso federado não foi ligado.", + "sso.save_refused_saml": "SAML 2.0 não está disponível nesta imagem.", + "sso.save_failed": "Não foi possível salvar a configuração.", + "sso.wrong_password": "Senha do painel incorreta: nada foi alterado.", + "sso.allowlist_required": "Preencha ao menos um domínio ou e-mail autorizado. SSO sem lista de autorizados deixa entrar toda conta do provedor.", + "sso.incomplete": "Preencha o endereço público, o issuer e o ID do cliente antes de ligar o SSO.", + "sso.no_secret": "Sem segredo do cliente: defina um aqui ou na variável de ambiente OIDC_CLIENT_SECRET.", + "sso.saml_refused": "SAML 2.0 não pode ser ligado nesta imagem.", + "sso.disabled_by_env": "A entrada federada está desligada pela variável de ambiente SSO_DISABLED. A configuração abaixo é mantida, mas nenhuma rota de SSO responde.", + "sso.login_button": "Entrar com {provider}", + "sso.need_base_url": "O endereço público do painel precisa ser esquema e host, sem " + "caminho, e https fora do loopback.", + "sso.need_issuer": "O emissor é obrigatório e precisa usar https fora do loopback.", + "sso.need_client_id": "O identificador do cliente é obrigatório.", + "sso.need_secret": "O segredo do cliente é obrigatório.", + "sso.need_allowlist": "Preencha ao menos um domínio ou e-mail autorizado.", + "sso.tunnel_warning": "O túnel rápido troca de endereço a cada subida, e todo endereço de " + "retorno cadastrado no provedor deixa de bater. O SSO exige túnel " + "nomeado ou Tailscale, com endereço fixo.", + "sso.sign_in_with": "Entrar com {provider}", + "sso.unavailable": "O provedor de identidade não respondeu. Entre com usuário e senha.", + "sso.or": "ou", + "sso.failed": "Não foi possível entrar pelo provedor de identidade. Tente de novo ou use usuário e senha.", + "sso.entering": "Entrando", + "sso.entering_body": "Acesso federado aceito. Abrindo o painel.", + "sso.status_on": "Ligado · {provider}", + "sso.status_off": "Desligado", + "sso.signing_in": "Entrando", + "sso.signing_in_body": "O provedor de identidade confirmou quem você é. Levando você ao painel.", + "sso.logout_note": "Sair apaga apenas a sessão do painel. A sessão no provedor de identidade continua aberta, então o clique seguinte no botão de SSO pode não pedir senha de novo.", + "sso.landing_title": "Entrando...", + "sso.landing_body": "A sessão foi criada. Levando você ao painel.", + "sso.idp_entity_id": "Identificador do provedor de identidade", + "sso.idp_sso_url": "Endereço de entrada do provedor de identidade", + "sso.idp_cert": "Certificado X.509 do provedor de identidade", + "sso.metadata_hint": "Depois de salvar, baixe a descrição do serviço em {url} já " + "autenticado e entregue-a ao provedor de identidade.", + "sso.idp_cert_help": "Certificado público, que pode ficar ao lado do resto da configuração.", + "sso.saml_unavailable": "SAML 2.0 não está disponível nesta imagem. Ele exige uma biblioteca que assina e confere XML, e fazer isso à mão aceitaria asserção forjada em silêncio. Os campos ficam aqui para que a configuração já esteja pronta quando a imagem trouxer a biblioteca.", + "sso.saml_pending": "A biblioteca de SAML2 está instalada, mas esta versão do painel só " + "entra por OIDC. A entrada por SAML2 é a próxima fase.", }, "es": { - "app.subtitle": "OmniRoute Universal Token & Connection Synchronizer", + "app.subtitle": f"{NOME_DO_GATEWAY} Universal Token & Connection Synchronizer", "app.gateway_unset": "gateway no configurado", "action.refresh": "Actualizar", "action.access": "Acceso", @@ -272,32 +664,71 @@ "anterior, así que vuelva a autenticarse para continuar.", "auth.updated_link": "Volver al panel", "auth.required": "Autenticación requerida.", + "auth.login_intro": "Entre para ver el panel.", + "auth.user": "Usuario", + "auth.password": "Contraseña", + "auth.enter": "Entrar", + "auth.login_failed": "Usuario o contraseña incorrectos.", + "auth.too_many": "Demasiados intentos", + "auth.too_many_body": "Espere {seconds}s antes de intentarlo de nuevo.", + "auth.challenge": "Verificación de seguridad", + "auth.challenge_prompt": "Selecciona el/la {item} para confirmar que eres humano:", + "auth.item_key": "Llave", + "auth.item_shield": "Escudo", + "auth.item_lock": "Candado", + "auth.item_star": "Estrella", + "auth.item_heart": "Corazón", + "auth.item_bell": "Campana", + "auth.item_lightning": "Rayo", + "auth.item_gear": "Engranaje", + "auth.logout": "Salir", "auth.required_body": "Este panel es privado. Autentíquese para continuar.", "gateway.title": "Conexión con el gateway", + "gateway.db_summary": "Operativo ({connections} conexiones, {combos} combos)", + "gateway.db_missing": "Base de datos no encontrada", "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", + # Vocabulário do ciclo: o trabalho que este agendador faz muda com o + # gateway, e o texto acompanha. + "cron.title": "Programador", + "cron.run_now": "Ejecutar ahora", "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_findings": "Hallazgos hasta ahora", "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)", + # Vocabulário do ciclo: o trabalho que este agendador faz muda com o + # gateway, e o texto acompanha. + "cron.result_line": "{inspected} · ({duration}ms)", + "cron.unavailable": "El programador no está disponible en esta instancia.", + "cron.failed": "No se pudo completar el ciclo: {error}", "connections.title": "Conexiones monitoreadas", "connections.empty": "No hay conexiones registradas en el gateway.", + "connections.empty_hint": f"En {NOME_DO_GATEWAY} la conexión es el destino detrás de los modelos " + "registrados; ninguno declara uno todavía.", + "connections.lifecycle_note": "El destino no tiene validez propia: lo que expira es la " + "credencial en el proveedor, fuera de este gateway.", + "connections.served_models": "Modelos servidos", "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.details": "Detalles", + "table.expires_at": "Expira el", + "table.rpm_limit": "Límite RPM", + "table.tpm_limit": "Límite TPM", + "table.max_budget": "Tope de presupuesto", + "table.api_base": "Base de la API", "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", @@ -306,13 +737,56 @@ "egress.shared": "comparte la dirección del gateway con {count} cuentas", "egress.single": "dirección del gateway (única cuenta)", "egress.unknown": "salida desconocida", + "egress.title": "Salida de red", "table.combo": "Combo", "table.cascade": "Cascada de modelos", "combos.title": "Combos de resiliencia", "combos.empty": "No hay combos de respaldo registrados.", + "combos.empty_hint": f"En {NOME_DO_GATEWAY} el combo es el fallback del enrutador " + "(router_settings.fallbacks); no hay ninguno declarado.", + "combos.kind_context_window": "ventana de contexto", + "combos.kind_content_policy": "política de contenido", + "keys.title": "Claves virtuales", + "keys.empty": "El gateway no ha emitido ninguna clave virtual.", + "table.alias": "Alias", + "table.team": "Equipo", + "table.spend": "Gasto", + "keys.enabled": "Aceptada", + "keys.disabled": "Desactivada", + "keys.access_all": "Todos los modelos", + "keys.access_restricted": "Restringida a los modelos listados", + "keys.revoked": "Revocada", + "keys.banned": "Bloqueada", + "models.title": "Modelos registrados", + "models.empty": "El gateway respondió con el catálogo de modelos vacío.", + "credential.env": "Guardada en el entorno del proxy", + "credential.named": "Credencial con nombre", + "credential.inline": "Declarada en el modelo", + "credential.absent": "No expuesta por la pasarela", + "limits.title": "Coherencia de los límites", + "limits.ok": "Todo límite respeta el nivel superior.", + "models.no_key": "El gateway no tiene ninguna clave activa emitida, y solo entrega el " + "catálogo de modelos a una clave emitida por él mismo.", + "models.unreachable": "El gateway no respondió al catálogo de modelos.", + "models.inherited": "El estado, la validez y la última renovación vienen de la conexión que sirve este modelo.", + "models.showing": "Mostrando {shown} de {total} modelos — la lista completa está en el gateway.", + "table.connection": "Conexión", + "table.issued_at": "Emitida el", + "table.last_used": "Último uso", + "table.scopes": "Ámbitos", + "table.key_state": "Estado de la clave", + "table.model_access": "Acceso a modelos", + "table.not_declared": "No declarado", + "table.source": "Origen", + "table.context_limit": "Límite de entrada", + "table.output_limit": "Límite de salida", + "table.endpoints": "Endpoints", + "table.description": "Descripción", "type.oauth": "OAuth 2.0", "type.api_key": "Clave de API", "type.local": "Local", + "type.virtual_key": "Clave virtual", + "type.synced_model": "Modelo sincronizado", "health.active": "Activo", "health.expiring_soon": "Por expirar", "health.expired": "Expirado", @@ -322,6 +796,13 @@ "health.invalid": "Rechazada", "health.unreachable": "Inaccesible", "health.not_checked": "Sin verificar", + "health.blocked": "Bloqueada", + "health.over_budget": "Presupuesto agotado", + "health.valid": "Aceptada", + "metric.virtual_keys": "Claves virtuales", + "metric.teams": "Equipos", + "metric.expiring": "Por vencer / vencidas", + "metric.limit_findings": "Incoherencias de límite", "gateway.diagnostics": "Diagnóstico", "gateway.diag_ok": "Gateway y base SQLite totalmente operativos", "gateway.diag_db_failed": "Gateway en línea, base ilegible", @@ -332,11 +813,13 @@ "action.validate": "Validar credenciales", "duration.unknown_expiry": "Validez desconocida", "duration.no_expiry": "Sin expiración (clave estática)", + "duration.no_expiry_short": "Sin expiración", "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.", + "action.cron_ran": "Ciclo ejecutado en {duration}ms: {inspected} inspeccionados, {findings} hallazgos.", "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.", @@ -351,15 +834,117 @@ "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.", + "auth.save_failed": "No se pudo guardar la nueva contraseña.", + "auth.env_managed": "Las credenciales de este panel se gestionan fuera de él. Cámbielas donde se configura el servicio y reinícielo.", + "pagination.range": "{inicio}-{fim} de {total}", + "pagination.label": "Paginación", "footer.signed_in": "Autenticado como", "footer.generated": "Datos generados en el servidor a las", + "language.save_failed": "No se pudo guardar el idioma: el almacenamiento del panel no acepta escritura.", "language.label": "Idioma", + "action.settings": "Configuración", + "sso.title": "Inicio de sesión federado (SSO)", + "sso.intro": "Opcional. El formulario de usuario y contraseña nunca sale de la pantalla, así que una caída del proveedor no deja a nadie fuera.", + "sso.tab_oidc": "OpenID Connect", + "sso.tab_saml": "SAML 2.0", + "sso.provider": "Proveedor activo", + "sso.provider_none": "Apagado (solo contraseña)", + "sso.provider_oidc": "OpenID Connect", + "sso.provider_saml": "SAML 2.0 (no disponible)", + "sso.enabled_help": "Un proveedor a la vez. Dos activos al mismo tiempo es lo que hace que " + "la respuesta de uno se acepte como si fuera la del otro.", + "sso.enabled_off": "Desactivado (solo contraseña)", + "sso.enabled_oidc": "OIDC", + "sso.base_url": "Dirección pública del panel", + "sso.base_url_hint": "El origen exacto que usa el navegador, sin barra al final. Tiene " + "que ser FIJO: un túnel rápido cambia de dirección en cada arranque " + "y toda dirección de retorno registrada en el proveedor deja de " + "coincidir, así que el acceso federado exige túnel con nombre o " + "Tailscale.", + "sso.base_url_help": "Origen público exacto, sin barra final. La dirección de retorno nace de este valor, y nunca de las cabeceras de la petición.", + "sso.redirect_uri": "Dirección de retorno que se registra en el proveedor", + "sso.callback_url": "Dirección de retorno a registrar en el proveedor", + "sso.callback_help": "Copie exactamente esta línea en el proveedor. Un carácter de " + "diferencia y rechaza el intercambio.", + "sso.issuer": "Emisor (issuer)", + "sso.issuer_help": "El emisor publicado en el documento de descubrimiento. Debe coincidir " + "carácter por carácter.", + "sso.client_id": "Identificador del cliente", + "sso.client_secret": "Secreto del cliente", + "sso.secret_stored": "Hay un secreto guardado. Deje el campo en blanco para conservarlo.", + "sso.secret_absent": "Todavía no hay secreto guardado.", + "sso.secret_missing": "Todavía no hay secreto guardado. Sin él, el SSO permanece apagado.", + "sso.secret_from_env": "El secreto viene de la variable de entorno OIDC_CLIENT_SECRET. Cámbielo allí y reinicie el servicio.", + "sso.secret_keep_help": "Nunca se vuelve a mostrar. Deje este campo vacío para conservar " + "el actual.", + "sso.secret_failed": "No se pudo guardar el secreto del cliente: el almacenamiento del " + "panel no acepta escritura.", + "sso.scopes": "Ámbitos", + "sso.scopes_help": "Separados por espacios. El valor por omisión cubre el correo y el perfil.", + "sso.allowed_domains": "Dominios autorizados", + "sso.allowed_emails": "Correos autorizados", + "sso.allowlist_hint": "Separados por coma, y la lista no puede quedar vacía: sin ella " + "entraría toda cuenta del proveedor.", + "sso.allowlist_help": "Separados por comas, y al menos uno de los dos. Una lista vacía significa que entra cualquier cuenta del proveedor, por eso el panel se niega a encender el SSO sin ella.", + "sso.local_password": "Su contraseña actual del panel", + "sso.local_password_help": "Se exige además de la sesión: quien secuestre una sesión de ocho horas podría, si no, apuntar el panel a un proveedor hostil y agregarse a la lista de autorizados.", + "sso.current_password": "Contraseña actual del panel", + "sso.confirm_hint": "Guardar pide la contraseña local otra vez: quien robe una sesión no " + "puede apuntar el panel a un proveedor hostil y ponerse en la lista.", + "sso.current_password_help": "Exigida además de la sesión: una cookie robada no puede " + "bastar para apuntar el panel a un proveedor hostil.", + "sso.save": "Guardar configuración de SSO", + "sso.saved": "Configuración de inicio de sesión federado guardada.", + "sso.turned_off": "Acceso federado apagado. El formulario local sigue funcionando.", + "sso.save_refused_password": "Contraseña del panel incorrecta: no se cambió nada.", + "sso.save_refused_allowlist": "Indique al menos un dominio o dirección: una lista vacía " + "dejaría entrar a toda cuenta del proveedor.", + "sso.save_refused_fields": "Complete todos los campos del proveedor elegido, incluida la " + "dirección pública de este panel.", + "sso.save_refused_secret": "No se pudo guardar el secreto del cliente en disco, así que " + "el acceso federado no se activó.", + "sso.save_refused_saml": "SAML 2.0 no está disponible en esta imagen.", + "sso.save_failed": "No se pudo guardar la configuración.", + "sso.wrong_password": "Contraseña del panel incorrecta: no se cambió nada.", + "sso.allowlist_required": "Complete al menos un dominio o correo autorizado. SSO sin lista de autorizados deja entrar a cualquier cuenta del proveedor.", + "sso.incomplete": "Complete la dirección pública, el issuer y el ID de cliente antes de encender el SSO.", + "sso.no_secret": "Sin secreto de cliente: defina uno aquí o en la variable de entorno OIDC_CLIENT_SECRET.", + "sso.saml_refused": "SAML 2.0 no se puede encender en esta imagen.", + "sso.disabled_by_env": "El inicio de sesión federado está apagado por la variable de entorno SSO_DISABLED. La configuración de abajo se conserva, pero ninguna ruta de SSO responde.", + "sso.login_button": "Entrar con {provider}", + "sso.need_base_url": "La dirección pública del panel debe ser esquema y host, sin ruta, y " + "https fuera del loopback.", + "sso.need_issuer": "El emisor es obligatorio y debe usar https fuera del loopback.", + "sso.need_client_id": "El identificador del cliente es obligatorio.", + "sso.need_secret": "El secreto del cliente es obligatorio.", + "sso.need_allowlist": "Complete al menos un dominio o correo autorizado.", + "sso.tunnel_warning": "El túnel rápido cambia de dirección en cada arranque, y toda " + "dirección de retorno registrada en el proveedor deja de coincidir. " + "El SSO exige un túnel con nombre o Tailscale, con dirección fija.", + "sso.sign_in_with": "Entrar con {provider}", + "sso.unavailable": "El proveedor de identidad no respondió. Entre con usuario y contraseña.", + "sso.or": "o", + "sso.failed": "No se pudo entrar por el proveedor de identidad. Inténtelo de nuevo o use usuario y contraseña.", + "sso.entering": "Entrando", + "sso.entering_body": "Acceso federado aceptado. Abriendo el panel.", + "sso.status_on": "Activo · {provider}", + "sso.status_off": "Apagado", + "sso.signing_in": "Entrando", + "sso.signing_in_body": "El proveedor de identidad confirmó quién es usted. Llevándolo al panel.", + "sso.logout_note": "Salir borra solo la sesión del panel. La sesión en el proveedor de identidad sigue abierta, así que el siguiente clic en el botón de SSO puede no pedir contraseña otra vez.", + "sso.landing_title": "Entrando...", + "sso.landing_body": "La sesión fue creada. Llevándolo al panel.", + "sso.idp_entity_id": "Identificador del proveedor de identidad", + "sso.idp_sso_url": "Dirección de entrada del proveedor de identidad", + "sso.idp_cert": "Certificado X.509 del proveedor de identidad", + "sso.metadata_hint": "Después de guardar, descargue la descripción del servicio en {url} " + "ya autenticado y entréguela al proveedor de identidad.", + "sso.idp_cert_help": "Certificado público, que puede quedar junto al resto de la configuración.", + "sso.saml_unavailable": "SAML 2.0 no está disponible en esta imagen. Requiere una biblioteca que firme y verifique XML, y hacerlo a mano aceptaría aserciones falsificadas en silencio. Los campos quedan aquí para que la configuración esté lista cuando la imagen traiga la biblioteca.", + "sso.saml_pending": "La biblioteca de SAML2 está instalada, pero esta versión del panel " + "solo entra por OIDC. La entrada por SAML2 es la próxima fase.", }, } @@ -375,7 +960,14 @@ def normalize_language(code: str) -> str: 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) + # O rótulo do produto vem primeiro: são as poucas chaves que dependem do que + # ESTE gateway faz, e elas moram em identidade.py justamente para que o + # catálogo abaixo possa ser o mesmo texto nos três irmãos. + text = ROTULOS_DO_PRODUTO.get(lang, {}).get(key) + if text is None: + text = TRANSLATIONS.get(lang, {}).get(key) + if text is None: + text = ROTULOS_DO_PRODUTO.get(DEFAULT_LANGUAGE, {}).get(key) if text is None: text = TRANSLATIONS[DEFAULT_LANGUAGE].get(key, key) if params: diff --git a/src/omini_rtksync/identidade.py b/src/omini_rtksync/identidade.py new file mode 100644 index 0000000..f8017bd --- /dev/null +++ b/src/omini_rtksync/identidade.py @@ -0,0 +1,112 @@ +"""Identidade deste produto: a ÚNICA fronteira entre os três painéis irmãos. + +9RTKSync, OminiRTKSync e LiteLlmRTKSync são clones em código. O que muda entre +eles são cores, nome, logo e a quem cada um se conecta — e nada mais. Este +arquivo é o lugar onde essa diferença mora. Depois dele, nenhum outro módulo +comum pode escrever uma cor em hexadecimal, o nome do produto ou o nome do +gateway à mão: quem precisar, importa daqui. + +Regra dura: este módulo NÃO importa nada de dentro do pacote. Ele é importado +por todos os outros, e um único import interno aqui criaria ciclo. + +O contrato é fechado: são estes nomes, nem um a mais. Um nome extra é um +componente que só um painel sabe desenhar, e a simetria acaba ali. O guarda +está em tests/test_identidade_visual.py. +""" + +# -------------------------------------------------------------------------- +# Nomes +# -------------------------------------------------------------------------- + +# Nome comercial, como aparece na aba do navegador e no cabeçalho do painel. +NOME_DO_PRODUTO = "OminiRTKSync" + +# Gateway que este sincronizador atende. +NOME_DO_GATEWAY = "OmniRoute" + +# Slug minúsculo do gateway. Quem emite a chave virtual é o próprio gateway: a +# coluna "Provedor" da tabela de chaves não tem outro valor possível. +PROVEDOR_DO_GATEWAY = "omniroute" + +# Prefixo dos contêineres da stack deste produto. +PREFIXO_DE_CONTAINER = "ominirtk-" + +# -------------------------------------------------------------------------- +# Portas publicadas no host. O valor efetivo continua vindo do ambiente +# (config.py); aqui fica só o padrão, para não haver duas fontes de verdade. +# -------------------------------------------------------------------------- + +PORTA_DO_PAINEL = 8082 +PORTA_DE_METRICAS = 9092 + +# -------------------------------------------------------------------------- +# Marca +# -------------------------------------------------------------------------- + +# Ícone do Bootstrap Icons que identifica o produto no cabeçalho. +ICONE_DO_PRODUTO = "bi-signpost-split-fill" + +# Desenho do ícone da aba, em markup SVG. Fica aqui inteiro porque o LiteLlm usa +# dois e os outros dois usam um só: guardar apenas o atributo `d` faria o +# template do favicon deixar de ser o mesmo texto nos três. +GLIFO_DO_FAVICON = ( + "" +) + +# Fundo do quadrado do favicon, já escapado para caber numa data URI. É o token +# --surface, e não a cor-base: sobre o --bg o glifo branco some. +COR_DO_FAVICON = "%23310a5c" + +# -------------------------------------------------------------------------- +# Paleta +# -------------------------------------------------------------------------- + +# Tokens de PAPEL: existem nos três painéis, com valores diferentes em cada um. +# A ordem é a ordem em que o :root os declara — ordem diferente é diff puro. +# O que NÃO está aqui (--text e os doze --bs-*) é estrutural: mesmo valor nos +# três, e por isso fica em render.py. +PALETA = { + "--bg": "#240046", # fundo da pagina + "--surface": "#310a5c", # cartao + "--surface-2": "#3d1270", # cabecalho de cartao, chip + "--line": "#4d1d88", # borda + "--accent": "#b57bff", # acao primaria + "--accent-2": "#d2aaff", # acao secundaria, realce + "--brand-a": "#7a2fd6", # marca, inicio do gradiente + "--brand-b": "#b57bff", # marca, fim do gradiente + "--text-dim": "#b9a6d4", # texto secundario +} + +# -------------------------------------------------------------------------- +# Cookies +# -------------------------------------------------------------------------- + +# Os três painéis podem ser abertos no mesmo navegador, no mesmo host, em portas +# diferentes — e cookie não se separa por porta. Sem o nome do produto no nome +# do cookie, entrar num painel derrubaria a sessão dos outros dois. +NOME_DO_COOKIE = "ominirtksync_sessao" +NOME_DO_COOKIE_DE_ESTADO = "ominirtksync_estado_sso" + +# As duas chaves de tradução que dependem do que ESTE gateway faz. O catálogo de +# `i18n.py` é o mesmo texto nos três irmãos; estas duas não podiam ser, porque o +# 9Router e o OmniRoute renovam credencial OAuth e o LiteLLM apenas inspeciona -- +# não há OAuth para renovar lá. Chamar os três de "agendador de renovação" +# deixaria um deles mentindo na tela. +# +# Ficam aqui, e não no catálogo, porque este é o arquivo onde mora o que muda de +# produto para produto. O `i18n.py` sobrepõe estas por cima das comuns. +ROTULOS_DO_PRODUTO = { + "en": { + "cron.title": "Renewal scheduler", + "cron.result_line": "{inspected} evaluated · {refreshed} renewed ({duration}ms)", + }, + "pt": { + "cron.title": "Agendador de renovação", + "cron.result_line": "{inspected} avaliadas · {refreshed} renovadas ({duration}ms)", + }, + "es": { + "cron.title": "Programador de renovación", + "cron.result_line": "{inspected} evaluadas · {refreshed} renovadas ({duration}ms)", + }, +} diff --git a/src/omini_rtksync/logs.py b/src/omini_rtksync/logs.py index 2311aca..f7755cf 100644 --- a/src/omini_rtksync/logs.py +++ b/src/omini_rtksync/logs.py @@ -7,7 +7,7 @@ Variáveis de ambiente: LOG_DIR Diretório dos arquivos de log. Padrão: /logs, - com fallback para ~/.ominirtksync/logs. + com fallback para ~/./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. @@ -21,7 +21,14 @@ from logging.handlers import TimedRotatingFileHandler from typing import Optional -LOG_FILE_NAME = "ominirtksync.log" +from .identidade import NOME_DO_PRODUTO + +# Apelido do produto em minúsculas: é o nome do arquivo, do logger e do +# diretório de fallback. Vem da identidade para que os irmãos, rodando na +# mesma máquina, nunca escrevam no mesmo arquivo. +APELIDO = NOME_DO_PRODUTO.lower() + +LOG_FILE_NAME = f"{APELIDO}.log" DEFAULT_RETENTION_DAYS = 30 _logger: Optional[logging.Logger] = None @@ -48,7 +55,7 @@ def resolve_log_dir(db_path: str = "") -> str: if parent and os.path.isdir(parent) and os.access(parent, os.W_OK): return candidate - return os.path.join(os.path.expanduser("~"), ".ominirtksync", "logs") + return os.path.join(os.path.expanduser("~"), f".{APELIDO}", "logs") def purge_expired_logs(log_dir: str, retention_days: Optional[int] = None) -> int: @@ -82,7 +89,7 @@ def setup_logging(db_path: str = "") -> logging.Logger: if _logger is not None: return _logger - logger = logging.getLogger("ominirtksync") + logger = logging.getLogger(APELIDO) logger.setLevel(getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)) logger.propagate = False logger.handlers.clear() diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py index b7dfaef..c96ee01 100644 --- a/src/omini_rtksync/models.py +++ b/src/omini_rtksync/models.py @@ -1,38 +1,134 @@ -"""OmniRoute connection model exposing the same interface the dashboard consumes. - -database.py returns dictionaries (the OmniRoute schema is relational). This layer -wraps them in an object carrying the derived properties the screen needs, keeping -the renderer identical to the sibling project 9RTKSync. +"""Registros que o painel mostra: conexões, chaves virtuais e modelos. + +Cada gateway devolve o seu JSON com uma forma própria -- um aninha o endereço do +provedor em ``providerSpecificData``, outro devolve colunas relacionais, um +terceiro não guarda conexão nenhuma e só devolve MODELOS, cada um declarando +para onde vai. Este arquivo é a lente comum: as propriedades DERIVADAS +(``is_oauth``, ``remaining_seconds``, ``health_status``, ``provider``) respondem +à mesma pergunta com a mesma regra nos três produtos, para que a tela não mude +de opinião conforme o gateway por trás dela. + +Ler formato é tolerância: quando dois gateways gravam a mesma coisa com nomes +diferentes, os dois nomes são aceitos e o campo ausente simplesmente não +responde. Decidir o que aquilo SIGNIFICA é regra de negócio, e regra de negócio +é uma só. + +Nenhuma projeção carrega credencial: ``to_dict`` diz se existe chave, nunca qual +é, e a chave virtual se identifica pelo apelido, pelo nome ou pelo id -- nunca +pelo prefixo do token, que é um pedaço do segredo. """ +import json import time from dataclasses import dataclass, field +from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from .normalizer import parse_expiry_to_ms +# Margem em que uma credencial já conta como "expirando" na tela. Vale para a +# conexão e para a chave virtual, para que o mesmo prazo pinte o mesmo amarelo +# nos dois cartões. +EXPIRING_SOON_SECONDS = 900 -# Provider names that suggest a local instance. A marker alone is not proof: -# "ollama" is also the name of Ollama Cloud, a hosted service that must never -# be probed on /api/tags. +# Vocabulário de saúde. É o mesmo texto que o render usa como chave do badge e +# da tradução: um estado que não esteja no mapa dele aparece como desconhecido, +# então inventar nome aqui apaga a informação na tela. +HEALTH_ACTIVE = "active" +HEALTH_EXPIRING_SOON = "expiring_soon" +HEALTH_EXPIRED = "expired" +HEALTH_NO_EXPIRATION = "no_expiration" +HEALTH_BLOCKED = "blocked" +HEALTH_OVER_BUDGET = "over_budget" +HEALTH_RATE_LIMITED = "rate_limited" +HEALTH_INVALID = "invalid" +HEALTH_UNREACHABLE = "unreachable" +HEALTH_NOT_CHECKED = "not_checked" +HEALTH_UNKNOWN = "unknown" + +# Nomes de provedor que sugerem uma instância local ou compatível com a API da +# OpenAI. O marcador sozinho não prova nada: "ollama" também é o nome do serviço +# hospedado, que jamais pode ser sondado em /api/tags. LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal", ".local") -# Threshold below which a token counts as "expiring soon" (15 min). -EXPIRING_SOON_SECONDS = 900 + +def parse_instant(value: Any) -> Optional[datetime]: + """Lê um instante em ISO-8601 ou em epoch, tolerando as duas formas. + + Um epoch numérico gravado como TEXTO é a origem da classe de defeito que deu + origem a esta família de projetos: quem só entende ISO devolve nada, e a tela + passa a dizer "sem validade" para uma credencial que tem prazo e está + vencendo. Valor que não dá para ler vira ``None`` -- nunca uma exceção, que + derrubaria a página inteira por causa de um campo torto. + """ + if value is None or isinstance(value, bool): + return None + if isinstance(value, (int, float)): + seconds = float(value) + if seconds <= 0: + return None + # Heurística de segundos contra milissegundos: 1e11 segundos é o ano + # 5138, então qualquer coisa acima disso só pode estar em milissegundos. + if seconds >= 1e11: + seconds /= 1000.0 + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + try: + return parse_instant(float(text)) + except ValueError: + pass + try: + moment = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + # Carimbo sem fuso é lido como UTC, que é o fuso em que os gateways gravam. + # Assumir o fuso da máquina faria o mesmo dado significar horas diferentes + # em dois servidores. + return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + + +def to_epoch_ms(value: Any) -> Optional[int]: + """O mesmo instante em epoch de milissegundos, que é como o painel compara.""" + moment = parse_instant(value) + return int(moment.timestamp() * 1000) if moment is not None else None @dataclass class ConnectionRecord: - """A provider_connections row seen through the panel's lens.""" - - id: str - provider: str - name: str + """Uma conexão com o provedor, vista pela lente do painel. + + A origem muda conforme o gateway: uma linha de ``providerConnections`` com o + JSON inteiro numa coluna, uma linha relacional já resolvida em dicionário, ou + o agrupamento dos modelos por destino quando o gateway não guarda conexão + nenhuma (veja ``group_connections``). O que não muda é o que a tela pergunta. + """ + + id: str = "" + provider: str = "" + name: str = "" + created_at: str = "" + updated_at: str = "" + data_raw: str = "" data: Dict[str, Any] = field(default_factory=dict) + def __post_init__(self): + # Gateway que guarda o payload como texto entrega aqui o JSON cru. JSON + # torto não pode derrubar a leitura de todas as outras conexões. + if self.data_raw and not self.data: + try: + self.data = json.loads(self.data_raw) + except Exception: + self.data = {} + @classmethod def from_row(cls, row: Dict[str, Any]) -> "ConnectionRecord": - """Build the record from the dictionary returned by get_all_connections.""" + """Monta o registro a partir do dicionário que a leitura do gateway devolve.""" return cls( id=str(row.get("id", "")), provider=str(row.get("provider", "")), @@ -42,10 +138,12 @@ def from_row(cls, row: Dict[str, Any]) -> "ConnectionRecord": @property def is_oauth(self) -> bool: + """Se a conexão se autentica por fluxo de token OAuth.""" return bool(self.data.get("refreshToken") or self.data.get("accessToken")) @property def has_api_key(self) -> bool: + """Se a conexão se autentica por chave de API estática.""" return bool(self.data.get("apiKey")) @property @@ -60,25 +158,12 @@ def access_token(self) -> Optional[str]: def refresh_token(self) -> Optional[str]: return self.data.get("refreshToken") - @property - def is_local(self) -> bool: - """Whether the connection really points at an instance on this machine. - - Classification is driven by the address, not by the provider name. Only - when no address is declared does a marker like "openai-compatible" -- - which has no hosted counterpart -- stand on its own. - """ - base_url = str(self.base_url or "").lower() - if base_url: - return any(host in base_url for host in LOCAL_HOSTS) - return "openai-compatible" in self.provider.lower() - @property def base_url(self) -> Optional[str]: """Endereço do provedor, onde quer que o gateway o tenha guardado. - O 9Router aninha em providerSpecificData; lendo só a raiz, instância - local nenhuma exibia seus modelos. + Um dos gateways aninha em ``providerSpecificData``; lendo só a raiz, + instância local nenhuma exibia os seus modelos. """ specific = self.data.get("providerSpecificData") if isinstance(specific, dict): @@ -87,59 +172,119 @@ def base_url(self) -> Optional[str]: return nested return ( self.data.get("baseUrl") + or self.data.get("baseURL") or self.data.get("base_url") or None ) + @property + def api_base(self) -> Optional[str]: + """O mesmo endereço com o nome que o cadastro de modelos usa.""" + return self.base_url + + @property + def is_local(self) -> bool: + """Se a conexão realmente aponta para uma instância nesta máquina. + + Quem classifica é o endereço, não o nome do provedor. Só quando nenhum + endereço foi declarado é que um marcador como "openai-compatible" -- que + não tem contraparte hospedada -- vale sozinho. + """ + base_url = str(self.base_url or "").lower() + if base_url: + return any(host in base_url for host in LOCAL_HOSTS) + # Sem endereço: "openai-compatible" só existe auto-hospedado, enquanto + # "ollama" sem baseUrl é a conta na nuvem. + return "openai-compatible" in self.provider.lower() + @property def local_models(self) -> List[str]: - """Models discovered on the local instance during the last sweep.""" + """Modelos descobertos na instância local na última varredura.""" models = self.data.get("discoveredModels") or self.data.get("models") or [] if isinstance(models, str): return [models] return [str(m) for m in models if m] + @property + def credential_name(self) -> Optional[str]: + """Nome que o operador deu à credencial no gateway -- nunca o valor dela.""" + name = self.data.get("credentialName") + return str(name) if name else None + + @property + def models(self) -> List["RegisteredModelRecord"]: + """Modelos servidos por este destino, quando a conexão veio do agrupamento.""" + return list(self.data.get("registeredModels") or []) + + @property + def model_names(self) -> List[str]: + return [model.name for model in self.models] + + @property + def identity(self) -> str: + """Chave de agrupamento do destino: provedor mais endereço.""" + return f"{self.provider}|{self.api_base or ''}" + @property def egress_status(self) -> str: - """Como esta conexao sai para a internet: ``bound``, ``shared`` ou ``unknown``. - - Somente leitura: quem manda no vinculo e o gateway. O OmniRoute guarda - os interruptores em ``provider_connections`` (``proxy_enabled`` e - ``per_key_proxy_enabled``) e o vinculo em si em ``proxy_assignments``, - com ``scope='account'`` e ``scope_id`` igual ao id da conexao. E exibido - aqui porque uma conta que compartilha o mesmo endereco de saida com - todas as outras e justamente o estado que o operador quer perceber, e - nada no painel mostrava isso. + """Como esta conta sai para a internet: ``bound``, ``shared`` ou ``unknown``. + + Somente leitura: quem manda no vínculo é o gateway. Um guarda os + interruptores em colunas da própria conexão (``proxyEnabled``, + ``perKeyProxyEnabled``) com o vínculo já resolvido em ``egressProxy``; + outro guarda tudo em ``providerSpecificData``. É exibido porque uma conta + que compartilha o mesmo endereço de saída com todas as outras é + justamente o estado que o operador precisa perceber antes do provedor. """ if self.data.get("proxyEnabled") is True or self.data.get("perKeyProxyEnabled") is True: return "bound" if self.data.get("egressProxy") else "shared" + specific = self.data.get("providerSpecificData") + if isinstance(specific, dict): + if specific.get("connectionProxyEnabled") is True and specific.get("proxyPoolId"): + return "bound" + return "shared" return "shared" if "proxyEnabled" in self.data else "unknown" @property def egress_binding(self) -> Optional[str]: - """Nome ou identificador da saida vinculada, quando ha uma.""" - vinculo = self.data.get("egressProxy") - return str(vinculo) if vinculo else None + """Nome ou identificador da saída vinculada, quando existe uma.""" + binding = self.data.get("egressProxy") + if binding: + return str(binding) + 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 expires_at_ms(self) -> Optional[int]: - """Expiry normalized to epoch milliseconds, whether ISO or numeric.""" - return parse_expiry_to_ms(self.data.get("expiresAt")) + """Expiração normalizada em epoch de milissegundos, venha ela como for.""" + return to_epoch_ms(self.data.get("expiresAt")) @property def remaining_seconds(self) -> Optional[int]: - exp = self.expires_at_ms - if exp is None: + """Segundos que faltam para a credencial expirar.""" + expires = self.expires_at_ms + if expires is None: return None - return int((exp - int(time.time() * 1000)) / 1000) + return int((expires - int(time.time() * 1000)) / 1000) + + @property + def is_expired(self) -> bool: + """Se a credencial já venceu.""" + remaining = self.remaining_seconds + return remaining is not None and remaining <= 0 @property def last_refresh_at(self) -> Optional[str]: - """Quando a credencial foi renovada/verificada pela ultima vez. + """Quando a credencial foi renovada ou verificada pela última 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. + ``lastRefreshAt`` é gravado na renovação de OAuth; ``lastTested``, na + validação da credencial. Sem expor isto, o painel diz "0 renovadas" e não + há como saber se a última renovação foi há um minuto ou há uma semana. """ return ( self.data.get("lastRefreshAt") @@ -150,7 +295,10 @@ def last_refresh_at(self) -> Optional[str]: @property def credential_state(self) -> Optional[str]: - """Resultado da última validação viva da credencial, quando houve uma.""" + """Resultado da última validação viva da credencial, quando houve uma. + + Quem escreve este campo é a sondagem deste painel, nunca o gateway. + """ state = self.data.get("credentialState") return str(state) if state else None @@ -158,62 +306,491 @@ def credential_state(self) -> Optional[str]: def rate_limit_active(self) -> bool: """Se a trava de rate limit ainda vale neste instante. - `rateLimitedUntil` é um prazo, não uma bandeira: ele guarda o momento em + ``rateLimitedUntil`` é um PRAZO, não uma bandeira: guarda o momento em que a janela do provedor se reabre. Tratar a simples presença do campo como "limitada" deixava a conexão amarela para sempre depois do primeiro 429, porque nada apaga a marca quando o prazo vence. """ - until = parse_expiry_to_ms(self.data.get("rateLimitedUntil")) + until = to_epoch_ms(self.data.get("rateLimitedUntil")) if until is None: return False return until > int(time.time() * 1000) @property def health_status(self) -> str: - """Semantic classification of the connection state. + """Classificação semântica do estado da conexão. - Uma validação viva vence tudo: chave que o provedor recusa está - quebrada, não importa o que o gateway tenha carimbado por último. + Uma validação viva vence tudo: chave que o provedor recusa está quebrada, + não importa o que o gateway tenha carimbado por último. As instâncias + locais são classificadas antes do ramo de chave de API porque carregam + uma chave de fachada e nunca chegariam ao teste que é delas. """ probed = self.credential_state - if probed in ("invalid", "rate_limited", "unreachable"): + if probed in (HEALTH_INVALID, HEALTH_RATE_LIMITED, HEALTH_UNREACHABLE): return probed if self.is_local: - # unreachable is written when the model catalog does not answer. - estado = self.data.get("testStatus") - if estado == "unreachable": - return "unknown" - # Uma conexão recém-criada ainda não foi sondada por ninguém. Com - # CRON_ENABLED=0 ela pode nunca ser, e dizer "ativa" é alegar uma - # saúde que nenhuma sonda confirmou. - return "active" if estado in ("active", "ok", "success") else "not_checked" + # "unreachable" é escrito quando o catálogo de modelos não responde. + stamped = self.data.get("testStatus") + if stamped == "unreachable": + return HEALTH_UNKNOWN + # Conexão recém-criada nunca foi sondada, e com o agendador desligado + # pode nunca ser: dizer "ativa" é alegar uma saúde que ninguém viu. + return HEALTH_ACTIVE if stamped in ("active", "ok", "success") else HEALTH_NOT_CHECKED if self.is_oauth: # O gateway já pode ter carimbado a conexão como recusada. **Sem uma # sonda viva que diga o contrário**, o carimbo dele é a melhor - # informação que existe — ignorá-lo mostrava como saudável uma - # credencial que o próprio OmniRoute sabe estar quebrada. Mas uma - # validação viva vence tudo: o carimbo é do último erro do gateway - # e não caduca sozinho, então honrá-lo mesmo depois de a sonda - # aprovar a credencial repetia, ao contrário, a própria contradição - # entre tela e banco que este arquivo existe para evitar. + # informação que existe -- ignorá-lo mostrava como saudável uma + # credencial que o próprio gateway sabe estar quebrada. Mas a + # validação viva vence: o carimbo é do último erro e não caduca + # sozinho, então honrá-lo depois de a sonda aprovar a credencial + # repetiria, ao contrário, a contradição entre tela e banco que este + # arquivo existe para evitar. if probed != "valid" and self.data.get("testStatus") in ("invalid", "error", "failed"): - return "invalid" + return HEALTH_INVALID remaining = self.remaining_seconds if remaining is None: - return "no_expiration" + return HEALTH_NO_EXPIRATION if remaining <= 0: - return "expired" + return HEALTH_EXPIRED if remaining < EXPIRING_SOON_SECONDS: - return "expiring_soon" - return "active" + return HEALTH_EXPIRING_SOON + return HEALTH_ACTIVE if self.has_api_key: if self.rate_limit_active: - return "rate_limited" - # Nunca sondada: dizer isso, em vez de alegar saúde que ninguém verificou. - return "active" if probed == "valid" else "not_checked" + return HEALTH_RATE_LIMITED + # Nunca sondada: dizer isso, em vez de alegar saúde que ninguém viu. + return HEALTH_ACTIVE if probed == "valid" else HEALTH_NOT_CHECKED + + # Um gateway escreve "ok", outro escreve "active"; os dois querem dizer + # a mesma coisa. + stamped = self.data.get("testStatus") + return HEALTH_ACTIVE if stamped in ("active", "ok", "success") else HEALTH_UNKNOWN + + def to_dict(self) -> Dict[str, Any]: + """Projeção explícita do destino. Nenhuma credencial entra aqui -- só o nome dela.""" + return { + "provider": self.provider, + "apiBase": self.api_base, + "credentialName": self.credential_name, + "models": self.model_names, + } + + +@dataclass +class VirtualKeyRecord: + """A chave virtual que o cliente apresenta ao gateway, vista pelo painel. + + Ela NÃO se renova: nasce com prazo (ou sem nenhum) e vence, ou vale até + alguém desativá-la. Por isso a coluna "última renovação" da tabela carrega + aqui a data de EMISSÃO -- é o único carimbo de tempo que a chave tem, e a + coluna existe para casar com a dos irmãos. + + O material do token não chega a este objeto: a leitura do gateway sequer + carrega a coluna onde ele mora. + """ + + data: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_row(cls, row: Dict[str, Any]) -> "VirtualKeyRecord": + return cls(data=dict(row)) + + @property + def id(self) -> str: + return str(self.data.get("id") or "") + + @property + def name(self) -> str: + """Como a chave se identifica na tela: o nome dado a ela, senão o id. + + O id é um UUID -- identifica sem revelar nada. O prefixo do token seria + mais reconhecível e está fora de questão: é um pedaço do segredo. + """ + return str(self.data.get("name") or self.id) + + @property + def alias(self) -> str: + """O apelido que o gateway guarda, quando o cadastro tem um. + + Sem apelido e sem nome, o que sobra é o FIM do token -- os últimos + caracteres identificam a linha para quem a emitiu e não reconstroem o + segredo. O começo, que é o que serve para autenticar, nunca aparece. + """ + for campo in ("key_alias", "key_name"): + if self.data.get(campo): + return str(self.data[campo]) + token = str(self.data.get("token") or "") + return f"…{token[-6:]}" if token else "(sem apelido)" + + @property + def team_id(self) -> Optional[str]: + value = self.data.get("team_id") + return str(value) if value else None + + @property + def created_at(self) -> Optional[str]: + """Quando a chave foi emitida -- o único carimbo de tempo que ela tem.""" + return ( + self.data.get("createdAt") + or self.data.get("created_at") + or self.data.get("created_by_at") + or None + ) + + @property + def issued_at(self) -> Optional[str]: + """O mesmo instante de emissão, com o nome que a tabela usa.""" + return self.created_at + + @property + def last_used_at(self) -> Optional[str]: + return self.data.get("lastUsedAt") + + @property + def machine_id(self) -> str: + """A máquina a que o gateway amarrou esta chave. + + Não é credencial: é uma impressão digital da instalação, que o próprio + gateway devolve ao emitir a chave. Aparece no modal porque é ela que + explica por que uma chave copiada para outra máquina deixa de funcionar. + """ + return str(self.data.get("machineId") or "") + + @property + def revoked(self) -> bool: + """Se o gateway já recusa esta chave, por qualquer um dos caminhos. + + Revogada, banida e desativada são caminhos diferentes para o mesmo fato + observável: a chave não é mais aceita. Há gateway que tem os três campos + e há gateway que só tem a bandeira de ativa; o que não existe simplesmente + não responde. + """ + return bool( + self.data.get("revokedAt") + or self.data.get("isBanned") + or self.data.get("isActive") is False + ) + + @property + def blocked(self) -> bool: + """Se a chave está bloqueada pelo gateway sem ter sido revogada.""" + return bool(self.data.get("blocked")) + + @property + def spend(self) -> float: + """Quanto já foi gasto por esta chave, quando o gateway contabiliza.""" + try: + return float(self.data.get("spend") or 0.0) + except (TypeError, ValueError): + return 0.0 + + @property + def max_budget(self) -> Optional[float]: + """Teto de gasto declarado. Zero é ausência de teto, não teto zerado.""" + try: + limit = float(self.data.get("max_budget")) + except (TypeError, ValueError): + return None + return limit if limit > 0 else None + + @property + def budget_exhausted(self) -> bool: + limit = self.max_budget + return limit is not None and self.spend >= limit + + @property + def scopes(self) -> List[str]: + return [str(s) for s in (self.data.get("scopes") or [])] + + @property + def allowed_models(self) -> List[str]: + return [str(m) for m in (self.data.get("allowedModels") or [])] + + @property + def model_access_mode(self) -> str: + return str(self.data.get("modelAccessMode") or "all") + + @property + def expires_at_ms(self) -> Optional[int]: + """Prazo da chave em epoch de milissegundos, quando o gateway declara um.""" + return to_epoch_ms(self.data.get("expiresAt") or self.data.get("expires")) + + @property + def remaining_seconds(self) -> Optional[int]: + """Segundos até o vencimento, ou ``None`` quando não há prazo declarado.""" + expires = self.expires_at_ms + if expires is None: + return None + return int((expires - int(time.time() * 1000)) / 1000) + + def health(self, margin_seconds: int = EXPIRING_SOON_SECONDS) -> str: + """Estado da chave, no mesmo vocabulário que as conexões usam. + + A ordem importa: recusa e bloqueio são fatos consumados; prazo vem + depois; orçamento estourado vem por último, porque uma chave vencida e + estourada é, antes de tudo, uma chave vencida. + + O último caso -- chave sem prazo nenhum declarado -- é o que os irmãos + ainda contam de formas diferentes, e é a última divergência deste + arquivo: um diz "sem expiração" e o outro diz "ativa". Validade não + declarada não é validade infinita, e enquanto a tela de um deles não + souber desenhar o estado do outro, trocar a palavra aqui apagaria a + informação em vez de unificá-la. + """ + if self.revoked: + return HEALTH_INVALID + if self.blocked: + return HEALTH_BLOCKED + remaining = self.remaining_seconds + if remaining is not None: + if remaining <= 0: + return HEALTH_EXPIRED + if remaining < margin_seconds: + return HEALTH_EXPIRING_SOON + if self.budget_exhausted: + return HEALTH_OVER_BUDGET + if remaining is None: + return HEALTH_NO_EXPIRATION + return HEALTH_ACTIVE + + @property + def health_status(self) -> str: + """O estado da chave, na margem que o painel usa para avisar.""" + return self.health(EXPIRING_SOON_SECONDS) + + def to_dict(self, margin_seconds: int = EXPIRING_SOON_SECONDS) -> Dict[str, Any]: + """Projeção explícita da chave. O token nunca sai daqui.""" + expires = self.expires_at_ms + return { + "alias": self.alias, + "teamId": self.team_id, + "expiresAt": ( + datetime.fromtimestamp(expires / 1000, tz=timezone.utc).isoformat() + if expires is not None + else None + ), + "remainingSeconds": self.remaining_seconds, + "blocked": self.blocked, + "spend": self.spend, + "maxBudget": self.max_budget, + "healthStatus": self.health(margin_seconds), + "models": [str(m) for m in (self.data.get("models") or [])], + "tpmLimit": self.data.get("tpm_limit"), + "rpmLimit": self.data.get("rpm_limit"), + } + + +@dataclass +class RegisteredModelRecord: + """Um modelo do catálogo do gateway, com a conexão que o serve. + + Modelo não tem saúde própria nem validade própria: ele responde enquanto a + credencial da conexão que o publica for aceita. Por isso status, validade + restante e última renovação são HERDADOS da conexão dona -- e o modal diz de + qual conexão vieram, para que ninguém leia a linha como um veredito sobre o + modelo em si. + """ + + data: Dict[str, Any] = field(default_factory=dict) + connection: Optional[ConnectionRecord] = None + + @classmethod + def from_row( + cls, row: Dict[str, Any], connection: Optional[ConnectionRecord] = None + ) -> "RegisteredModelRecord": + return cls(data=dict(row), connection=connection) + + @classmethod + def from_entry( + cls, entry: Dict[str, Any], connection: Optional[ConnectionRecord] = None + ) -> "RegisteredModelRecord": + return cls(data=dict(entry), connection=connection) + + @property + def id(self) -> str: + """Identificador do cadastro. - # OmniRoute writes "active"; 9Router writes "ok". Both mean healthy. - return "active" if self.data.get("testStatus") in ("active", "ok") else "unknown" + Onde há deployment, é o id DELE que identifica a linha: dois cadastros + podem publicar o mesmo nome de modelo, e o gateway trata isso como + recurso, não como erro. + """ + info = self.data.get("model_info") + if isinstance(info, dict) and info.get("id"): + return str(info["id"]) + return str(self.data.get("id") or "") + + @property + def model_id(self) -> str: + """O mesmo id, com o nome pelo qual o veredito de saúde o procura.""" + return self.id + + @property + def name(self) -> str: + """Como o cadastro se identifica na tela. + + O travessão no fim não é decoração: `id` cai para string vazia quando o + gateway não devolve nem `model_info.id` nem `id`, e sem ele a célula do + grid ficaria em branco -- o operador leria uma linha sem saber a que + cadastro ela se refere. Travessão é o mesmo sinal que o resto do painel + usa para "não declarado". + """ + return str(self.data.get("name") or self.data.get("model_name") + or self.id or "—") + + @property + def params(self) -> Dict[str, Any]: + """Parâmetros do cadastro, quando o gateway os devolve em bloco.""" + value = self.data.get("litellm_params") + return value if isinstance(value, dict) else {} + + @property + def provider(self) -> str: + """Quem serve o modelo: o campo declarado, ou o prefixo do nome técnico.""" + declared = self.data.get("provider") + if declared: + return str(declared) + model = str(self.params.get("model") or "") + return model.split("/", 1)[0] if "/" in model else model + + @property + def source(self) -> str: + """De onde o gateway tirou esta entrada do catálogo.""" + return str(self.data.get("source") or "") + + @property + def description(self) -> str: + return str(self.data.get("description") or "") + + @property + def input_token_limit(self) -> Optional[int]: + return self.data.get("inputTokenLimit") + + @property + def output_token_limit(self) -> Optional[int]: + return self.data.get("outputTokenLimit") + + @property + def supported_endpoints(self) -> List[str]: + return [str(e) for e in (self.data.get("supportedEndpoints") or [])] + + @property + def api_base(self) -> Optional[str]: + """Endereço para onde este cadastro manda a requisição.""" + value = self.params.get("api_base") + return str(value) if value else None + + @property + def api_key(self) -> str: + """Chave declarada no cadastro do modelo. + + Pode vir como referência de ambiente; nesse caso não há segredo aqui e + não há o que validar a partir do cadastro. O valor nunca é projetado. + """ + value = self.params.get("api_key") + return str(value) if value else "" + + @property + def key_is_env_reference(self) -> bool: + return self.api_key.startswith("os.environ/") + + @property + def uses_named_credential(self) -> bool: + return bool(self.params.get("litellm_credential_name")) + + @property + def connection_name(self) -> Optional[str]: + return self.connection.name if self.connection else None + + @property + def health_status(self) -> str: + # Sem conexão dona identificada (catálogo estático do gateway, ou órfão + # de uma conexão removida) não há o que afirmar: dizer "ativo" seria + # inventar uma sondagem que nunca houve. + return self.connection.health_status if self.connection else HEALTH_NOT_CHECKED + + @property + def remaining_seconds(self) -> Optional[int]: + return self.connection.remaining_seconds if self.connection else None + + @property + def last_refresh_at(self) -> Optional[str]: + return self.connection.last_refresh_at if self.connection else None + + def to_dict(self) -> Dict[str, Any]: + """Projeção explícita do modelo. Booleanos sobre a chave, nunca o valor.""" + return { + "name": self.name, + "provider": self.provider, + "apiBase": self.api_base, + "hasApiKey": bool(self.api_key), + "keyIsEnvReference": self.key_is_env_reference, + "usesNamedCredential": self.uses_named_credential, + } + + +def group_connections(models: List[RegisteredModelRecord]) -> List[ConnectionRecord]: + """Agrupa os modelos cadastrados nos destinos que eles realmente usam. + + Há gateway que guarda a lista de conexões e há gateway que não guarda: ele + guarda MODELOS, e cada modelo declara para onde vai e com que credencial. Sem + a lista, a conexão é o que sobra ao agrupar os modelos por destino -- cada par + (provedor, endereço) é um endpoint de verdade, com uma credencial e um + conjunto de modelos servidos por ela. Foi esta leitura, e não "o próprio + gateway é a única conexão", porque a segunda diz sempre a mesma coisa (uma + linha, sempre saudável) e não ajuda ninguém a descobrir qual provedor parou. + + A ordem de saída é a da primeira aparição de cada destino, para que a tabela + não mude de ordem entre dois carregamentos sem nada ter mudado no gateway. + """ + grouped: Dict[str, Dict[str, Any]] = {} + for model in models: + key = f"{model.provider}|{model.api_base or ''}" + bucket = grouped.get(key) + if bucket is None: + bucket = {"provider": model.provider, "api_base": model.api_base, + "credential_name": None, "models": []} + grouped[key] = bucket + # Modelos do mesmo destino podem declarar credenciais diferentes; o + # primeiro nome encontrado vale como rótulo, e o modal mostra os modelos + # para quem precisar conferir caso a caso. + named = model.params.get("litellm_credential_name") + if bucket["credential_name"] is None and named: + bucket["credential_name"] = str(named) + bucket["models"].append(model) + + # O nome da conexão é o rótulo que o operador reconhece: o nome da credencial + # quando há um; senão o endereço do destino, que é a única identificação + # honesta; senão o provedor. + return [ + ConnectionRecord( + id=key, + provider=bucket["provider"], + name=(bucket["credential_name"] or bucket["api_base"] + or bucket["provider"] or "(sem destino)"), + data={ + "baseUrl": bucket["api_base"], + "credentialName": bucket["credential_name"], + "registeredModels": bucket["models"], + }, + ) + for key, bucket in grouped.items() + ] + + +def summarize(keys: List[VirtualKeyRecord], + margin_seconds: int = EXPIRING_SOON_SECONDS) -> Dict[str, int]: + """Contagem por estado, para o cabeçalho do painel.""" + summary = { + HEALTH_ACTIVE: 0, + HEALTH_EXPIRING_SOON: 0, + HEALTH_EXPIRED: 0, + HEALTH_BLOCKED: 0, + HEALTH_OVER_BUDGET: 0, + } + for key in keys: + state = key.health(margin_seconds) + summary[state] = summary.get(state, 0) + 1 + return summary diff --git a/src/omini_rtksync/normalizer.py b/src/omini_rtksync/normalizer.py index 98a6544..3ca4904 100644 --- a/src/omini_rtksync/normalizer.py +++ b/src/omini_rtksync/normalizer.py @@ -1,6 +1,6 @@ -"""Normalização de datas e auto-cura para OmniRoute.""" +"""Normalização de datas e auto-cura no banco do gateway.""" -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Optional @@ -27,7 +27,15 @@ def parse_expiry_to_ms(val: Any) -> Optional[int]: iso_clean = val.replace("Z", "+00:00") try: dt = datetime.fromisoformat(iso_clean) - return int(dt.timestamp() * 1000) except Exception: pass + else: + # Carimbo SEM fuso e lido como UTC, que e como os gateways gravam -- + # o mesmo criterio de `models.parse_instant`. Deixar o Python assumir + # o fuso da maquina fazia este modulo e o models discordarem em horas + # sobre o MESMO campo: um apagava a trava de rate limit por + # considera-la vencida enquanto o outro ainda a desenhava na tela. + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) return None diff --git a/src/omini_rtksync/paginacao.py b/src/omini_rtksync/paginacao.py new file mode 100644 index 0000000..b1bd3dd --- /dev/null +++ b/src/omini_rtksync/paginacao.py @@ -0,0 +1,149 @@ +"""Paginação dos grids do painel: dez linhas por página, sempre. + +Um catálogo de gateway chega a centenas de modelos -- foram 550 numa medição -- +e despejar isso numa página faz a tela rolar por minutos até o rodapé. Dez por +vez é o suficiente para olhar sem perder o resto de vista. + +Três decisões que moldam este módulo, e valem registrar porque cada uma tem uma +alternativa óbvia e pior: + +**Cada grid pagina sozinho.** O parâmetro carrega o nome do grid +(`?pag_modelos=2`), e não um `?pag=2` global. Com um só, avançar a página dos +modelos moveria junto a tabela de conexões, que ninguém pediu para mexer. + +**O contador continua mostrando o total.** A paginação muda o que se vê, não o +que existe: um cabeçalho que passasse a dizer "10" depois de paginar faria a +tela mentir sobre o tamanho do catálogo -- e é justamente esse número que o +operador usa para saber que há 550 modelos lá. + +**Funciona sem JavaScript.** A página é montada no servidor e o compromisso de +funcionar com o script desligado já está declarado no resto do painel; a +paginação seria o único ponto a quebrá-lo. Os links são links de verdade, +carregando a mesma página com outro parâmetro. +""" + +from typing import Any, Dict, List, Optional, Sequence, Tuple +from urllib.parse import urlencode + +# Dez por página, em todos os grids dos três painéis. +POR_PAGINA = 10 + + +def _pagina_pedida(consulta: Optional[Dict[str, List[str]]], nome: str) -> int: + """Lê o número da página para este grid, tolerando lixo na query.""" + if not consulta: + return 1 + bruto = (consulta.get(f"pag_{nome}") or ["1"])[0] + try: + pagina = int(bruto) + except (TypeError, ValueError): + # `?pag_modelos=abc` não é motivo para derrubar a página inteira. + return 1 + return pagina if pagina >= 1 else 1 + + +def recortar( + itens: Sequence[Any], + nome: str, + consulta: Optional[Dict[str, List[str]]] = None, + por_pagina: int = POR_PAGINA, +) -> Tuple[List[Any], Dict[str, Any]]: + """Devolve a fatia visível e o estado da paginação daquele grid. + + O estado carrega o TOTAL, e não o tamanho da fatia: quem desenha o cabeçalho + precisa do número inteiro. + """ + total = len(itens) + ultima = max(1, (total + por_pagina - 1) // por_pagina) + # Uma página além do fim vira a última, em vez de uma tabela vazia sem + # explicação -- isso acontece sozinho quando o catálogo encolhe entre dois + # carregamentos e o link da página 7 continua no histórico do navegador. + pagina = min(_pagina_pedida(consulta, nome), ultima) + inicio = (pagina - 1) * por_pagina + return list(itens[inicio : inicio + por_pagina]), { + "nome": nome, + "pagina": pagina, + "ultima": ultima, + "total": total, + "inicio": inicio + 1 if total else 0, + "fim": min(inicio + por_pagina, total), + "por_pagina": por_pagina, + } + + +def _link(consulta: Optional[Dict[str, List[str]]], nome: str, pagina: int) -> str: + """Monta a URL desta página preservando os demais parâmetros. + + Preservar importa: sem isso, avançar a página dos modelos zeraria a página + das conexões e apagaria o aviso da última ação. + """ + parametros: Dict[str, str] = {} + for chave, valores in (consulta or {}).items(): + if not valores: + continue + # O aviso da última ação é de uso único: repeti-lo a cada clique de + # página faria a mesma mensagem reaparecer indefinidamente. + if chave in ("aviso", "tom"): + continue + parametros[chave] = valores[0] + parametros[f"pag_{nome}"] = str(pagina) + return "?" + urlencode(parametros) + + +def render_paginacao(estado: Dict[str, Any], consulta, traduzir, lang: str) -> str: + """Desenha a barra de páginas. Some sozinha quando há uma página só.""" + if estado["ultima"] <= 1: + return "" + + nome = estado["nome"] + pagina = estado["pagina"] + ultima = estado["ultima"] + + def item(rotulo: str, destino: int, ativo: bool = False, morto: bool = False) -> str: + if morto: + return ( + f'
  • {rotulo}
  • ' + ) + if ativo: + return ( + f'
  • ' + f'{rotulo}
  • ' + ) + return ( + f'
  • {rotulo}
  • ' + ) + + # Uma janela em volta da página atual: com 55 páginas, listar todas daria + # uma barra mais alta que a própria tabela. + primeira_visivel = max(1, pagina - 2) + ultima_visivel = min(ultima, primeira_visivel + 4) + primeira_visivel = max(1, ultima_visivel - 4) + + itens = [item("«", pagina - 1, morto=pagina <= 1)] + if primeira_visivel > 1: + itens.append(item("1", 1)) + if primeira_visivel > 2: + itens.append(item("…", 0, morto=True)) + for numero in range(primeira_visivel, ultima_visivel + 1): + itens.append(item(str(numero), numero, ativo=numero == pagina)) + if ultima_visivel < ultima: + if ultima_visivel < ultima - 1: + itens.append(item("…", 0, morto=True)) + itens.append(item(str(ultima), ultima)) + itens.append(item("»", pagina + 1, morto=pagina >= ultima)) + + intervalo = traduzir( + "pagination.range", + lang, + inicio=estado["inicio"], + fim=estado["fim"], + total=estado["total"], + ) + return f""" +
    + {intervalo} + +
    """ diff --git a/src/omini_rtksync/prefs.py b/src/omini_rtksync/prefs.py index 58d7a30..a418c69 100644 --- a/src/omini_rtksync/prefs.py +++ b/src/omini_rtksync/prefs.py @@ -1,7 +1,7 @@ """Preferências da interface persistidas em SQLite. Usa um banco próprio do sincronizador, nunca o SQLite do gateway: escrever -tabelas nossas no banco do OmniRoute criaria acoplamento de schema e risco de +tabelas nossas no banco do gateway 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 diff --git a/src/omini_rtksync/protecao.py b/src/omini_rtksync/protecao.py new file mode 100644 index 0000000..f7610e2 --- /dev/null +++ b/src/omini_rtksync/protecao.py @@ -0,0 +1,175 @@ +"""Freio contra força bruta e varredura automatizada, sem depender de ninguém. + +Um painel preso ao loopback não precisa disso. Um painel atrás de um túnel +precisa, e o túnel é um botão que o operador aperta quando quiser — então o +freio tem de já estar aqui quando ele apertar. + +Três camadas, da mais barata para a mais cara: + +1. **Teto por janela.** Mais de `TENTATIVAS_POR_JANELA` tentativas de login no + mesmo endereço dentro de `JANELA_EM_SEGUNDOS` devolve **429** com + `Retry-After`. É o que para o script que tenta mil senhas por minuto. + +2. **Espera que cresce.** Cada falha seguida atrasa a resposta seguinte, dobrando + até um teto. Um humano que errou a senha espera meio segundo; um robô que erra + sempre passa a esperar mais. O atraso é do lado do servidor: não há nada no + cliente para desligar. + +3. **Desafio interativo direto.** Depois de `FALHAS_ATE_DESAFIO` falhas, o formulário + só é aceito com a seleção do item solicitado entre opções visuais. Instantâneo + para humanos (1 clique, zero travamento de CPU ou spinner), e barra scripts e + robôs que tentam ataques automatizados em massa. + +O estado vive em memória, por processo. Reiniciar zera os contadores, o que é +aceitável: reiniciar é justamente o que um atacante não consegue fazer. +""" + +import secrets +import threading +import time +from typing import Any, Dict, List, Optional, Tuple + +JANELA_EM_SEGUNDOS = 300 +TENTATIVAS_POR_JANELA = 10 + +FALHAS_ATE_DESAFIO = 3 +ESPERA_INICIAL_EM_SEGUNDOS = 0.5 +ESPERA_MAXIMA_EM_SEGUNDOS = 5.0 + +# Quantidade padrão e máxima de opções exibidas no desafio interativo. +DIFICULDADE = 4 +DIFICULDADE_MAXIMA = 6 + +# Catálogo de itens do desafio interativo (identificador, ícone do Bootstrap Icons) +ITENS_DESAFIO: Tuple[Tuple[str, str], ...] = ( + ("key", "bi-key-fill"), + ("shield", "bi-shield-fill"), + ("lock", "bi-lock-fill"), + ("star", "bi-star-fill"), + ("heart", "bi-heart-fill"), + ("bell", "bi-bell-fill"), + ("lightning", "bi-lightning-fill"), + ("gear", "bi-gear-fill"), +) +MAPA_ICONES: Dict[str, str] = dict(ITENS_DESAFIO) + + +def icone_do_item(item: str) -> str: + """Ícone Bootstrap correspondente ao item do desafio.""" + return MAPA_ICONES.get(item, "bi-question-circle") + + +def dificuldade_para(endereco: str) -> int: + """Quantas opções exigir deste endereço, dado o histórico dele.""" + with _trava: + falhas = _falhas.get(endereco, 0) + extra = max(0, (falhas - FALHAS_ATE_DESAFIO) // 3) + return min(DIFICULDADE + extra, DIFICULDADE_MAXIMA) + + +_trava = threading.Lock() +_tentativas: Dict[str, List[float]] = {} +_falhas: Dict[str, int] = {} +_desafios: Dict[str, Any] = {} + + +def _limpa(agora: float) -> None: + """Descarta o que saiu da janela, para a memória não crescer sem limite.""" + for endereco in list(_tentativas): + recentes = [t for t in _tentativas[endereco] if agora - t < JANELA_EM_SEGUNDOS] + if recentes: + _tentativas[endereco] = recentes + else: + _tentativas.pop(endereco, None) + _falhas.pop(endereco, None) + for desafio, info in list(_desafios.items()): + criado = info.get("criado_em", 0.0) if isinstance(info, dict) else info + if agora - criado > JANELA_EM_SEGUNDOS: + _desafios.pop(desafio, None) + + +def registra_tentativa(endereco: str, agora: Optional[float] = None) -> Tuple[bool, int]: + """Anota uma tentativa. Devolve (pode_seguir, segundos_para_tentar_de_novo).""" + agora = agora if agora is not None else time.time() + with _trava: + _limpa(agora) + marcas = _tentativas.setdefault(endereco, []) + marcas.append(agora) + if len(marcas) > TENTATIVAS_POR_JANELA: + espera = int(JANELA_EM_SEGUNDOS - (agora - marcas[0])) + 1 + return False, max(espera, 1) + return True, 0 + + +def espera_por_falhas(endereco: str) -> float: + """Quanto o servidor segura a resposta, dado o histórico de falhas.""" + with _trava: + falhas = _falhas.get(endereco, 0) + if falhas <= 0: + return 0.0 + return min(ESPERA_INICIAL_EM_SEGUNDOS * (2 ** (falhas - 1)), ESPERA_MAXIMA_EM_SEGUNDOS) + + +def anota_falha(endereco: str) -> int: + with _trava: + _falhas[endereco] = _falhas.get(endereco, 0) + 1 + return _falhas[endereco] + + +def limpa_apos_sucesso(endereco: str) -> None: + """Quem acertou a senha deixa de ser suspeito.""" + with _trava: + _falhas.pop(endereco, None) + _tentativas.pop(endereco, None) + + +def precisa_de_desafio(endereco: str) -> bool: + with _trava: + return _falhas.get(endereco, 0) >= FALHAS_ATE_DESAFIO + + +def novo_desafio(quantidade: Optional[int] = None) -> str: + """Cria um desafio interativo de uso único, válido pela mesma janela do teto.""" + qtd = DIFICULDADE if quantidade is None else max(3, min(quantidade, len(ITENS_DESAFIO))) + desafio_id = secrets.token_hex(16) + escolhidos = secrets.SystemRandom().sample(ITENS_DESAFIO, qtd) + alvo = secrets.choice(escolhidos)[0] + with _trava: + _desafios[desafio_id] = { + "criado_em": time.time(), + "alvo": alvo, + "opcoes": [item[0] for item in escolhidos], + } + return desafio_id + + +def detalhes_do_desafio(desafio_id: str) -> Optional[Dict[str, Any]]: + """Devolve as opções e o item alvo do desafio, sem consumi-lo.""" + with _trava: + info = _desafios.get(desafio_id) + if not info or not isinstance(info, dict): + return None + return { + "id": desafio_id, + "alvo": info["alvo"], + "opcoes": list(info["opcoes"]), + } + + +def resposta_confere(desafio: str, resposta: str, dificuldade: Optional[int] = None) -> bool: + """Confere a resposta do desafio e o consome (uso único).""" + if not desafio or not resposta: + return False + with _trava: + info = _desafios.pop(desafio, None) + if not info or not isinstance(info, dict): + return False + return str(resposta).strip().lower() == str(info.get("alvo", "")).strip().lower() + + +def endereco_do_cliente(client_address) -> str: + """Só o endereço, sem a porta de origem, que muda a cada conexão.""" + try: + return str(client_address[0]) + except (TypeError, IndexError): + return "desconhecido" diff --git a/src/omini_rtksync/providers.py b/src/omini_rtksync/providers.py deleted file mode 100644 index 07d343a..0000000 --- a/src/omini_rtksync/providers.py +++ /dev/null @@ -1,410 +0,0 @@ -"""Provedores universais de tokens e conexões para OmniRoute.""" - -import json -import os -import time -import urllib.error -import urllib.parse -import urllib.request -from typing import Any, Dict, List, Optional, Tuple - -from .credential_check import ( - DEFAULT_TIMEOUT_SECONDS, - STATE_INVALID, - STATE_RATE_LIMITED, - STATE_UNREACHABLE, - STATE_VALID, - check_connection, -) -from .models import ConnectionRecord - - -class GoogleProvider: - """Renovador OAuth para contas Google (Antigravity / Gemini CLI) no OmniRoute.""" - - OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" - - def __init__(self, credential_paths: Optional[List[str]] = None, discovery: Optional[Any] = None): - self.credential_paths = credential_paths or [] - self.discovery = discovery - - def find_local_credential_file(self) -> Optional[str]: - if self.discovery: - disc = self.discovery.discover_google() - if disc and disc.get("source_path"): - return disc["source_path"] - for p in self.credential_paths: - if p and os.path.exists(p) and os.path.isfile(p): - return p - return None - - def read_local_credential(self) -> Optional[Dict[str, Any]]: - if self.discovery: - disc = self.discovery.discover_google() - if disc and disc.get("accessToken"): - return { - "access_token": disc.get("accessToken"), - "refresh_token": disc.get("refreshToken"), - "client_id": disc.get("clientId"), - "client_secret": disc.get("clientSecret"), - "expiry": disc.get("expiry"), - } - path = self.find_local_credential_file() - if not path: - return None - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - tok = data.get("access_token") or data.get("accessToken") or data.get("token") - if tok and "access_token" not in data: - data["access_token"] = tok - return data if isinstance(data, dict) else None - except Exception: - return None - - def refresh( - self, refresh_token: str, client_id: str, client_secret: str - ) -> Tuple[bool, Optional[Dict[str, Any]], str]: - if not client_id or not client_secret: - return False, None, "client_id ou client_secret não configurado no ambiente nem encontrado em shared.js" - payload = urllib.parse.urlencode({ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_id, - "client_secret": client_secret, - }).encode("utf-8") - - req = urllib.request.Request( - self.OAUTH_TOKEN_URL, - data=payload, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "OminiRTKSync/1.0", - }, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=20.0) as resp: - data = json.loads(resp.read().decode("utf-8")) - return True, data, "OK" - except urllib.error.HTTPError as e: - err_body = e.read().decode("utf-8", errors="replace")[:300] - return False, None, f"HTTP {e.code}: {err_body}" - except Exception as e: - return False, None, str(e) - - -class GenericOAuthProvider: - """Monitor e sincronizador OAuth genérico para OmniRoute (Claude, GitHub, Codex, Kiro).""" - - KNOWN_TOKEN_URLS = { - "claude": "https://api.anthropic.com/v1/oauth/token", - "github": "https://github.com/login/oauth/access_token", - "kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", - "codex": "https://auth.openai.com/oauth/token", - "kimi": "https://api.moonshot.cn/v1/oauth/token", - } - - def __init__(self, discovery: Optional[Any] = None): - self.discovery = discovery - - def can_handle(self, conn: Dict[str, Any]) -> bool: - provider = conn.get("provider", "").lower() - return bool(conn.get("isOAuth")) and provider not in ("antigravity", "gemini-cli") - - def check_and_refresh( - self, conn: Dict[str, Any], margin_seconds: int = 900 - ) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: - messages = [] - now_ms = int(time.time() * 1000) - provider = conn.get("provider", "") - - # 1. Verifica se há credencial local descoberta no host - if self.discovery: - local = self.discovery.get_credential_for_provider(provider) - if local and local.get("accessToken") and local.get("accessToken") != conn.get("accessToken"): - exp_ms = now_ms + (3599 * 1000) - res = { - "accessToken": local["accessToken"], - "refreshToken": local.get("refreshToken") or conn.get("refreshToken"), - "expiresAt": exp_ms, - } - src = local.get("source_path", "host") - messages.append(f"Token sincronizado a partir do host ({src})") - return True, res, messages - - # 2. Avaliação de expiração - from .normalizer import parse_expiry_to_ms - exp_ms = parse_expiry_to_ms(conn.get("expiresAt")) - if not exp_ms: - messages.append("Conexão OAuth sem registro temporal de expiração") - return False, None, messages - - rem = int((exp_ms - now_ms) / 1000) - if rem > margin_seconds: - messages.append(f"Token válido por mais {rem // 60} min ({rem}s)") - return False, None, messages - - # 3. Tentativa de refresh - refresh_token = conn.get("refreshToken") - token_url = self.KNOWN_TOKEN_URLS.get(provider.lower()) - client_id = os.environ.get(f"{provider.upper()}_CLIENT_ID") - client_secret = os.environ.get(f"{provider.upper()}_CLIENT_SECRET") - - if token_url and refresh_token and client_id: - try: - body = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_id, - } - if client_secret: - body["client_secret"] = client_secret - payload = urllib.parse.urlencode(body).encode("utf-8") - req = urllib.request.Request( - token_url, - data=payload, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - "User-Agent": "OminiRTKSync/1.0", - }, - method="POST", - ) - with urllib.request.urlopen(req, timeout=15.0) as resp: - data = json.loads(resp.read().decode("utf-8")) - new_tok = data.get("access_token") or data.get("accessToken") - if new_tok: - exp_in = int(data.get("expires_in", 3600)) - res = { - "accessToken": new_tok, - "refreshToken": data.get("refresh_token", refresh_token), - "expiresAt": now_ms + (exp_in * 1000), - } - messages.append(f"Token OAuth renovado com sucesso ({exp_in}s)") - return True, res, messages - except Exception as e: - messages.append(f"Refresh remoto retornou: {e}") - - messages.append(f"Token próximo da expiração ({rem}s restantes)") - return False, None, messages - - -class ApiKeyProvider: - """Gerenciador e sanitizador para conexões de API Key no OmniRoute.""" - - def __init__( - self, - discovery: Optional[Any] = None, - validate_credentials: bool = False, - validation_timeout: float = DEFAULT_TIMEOUT_SECONDS, - opener: Optional[Any] = None, - ): - self.discovery = discovery - # Desligado por padrão: montar o provider não pode gerar tráfego de saída. - self.validate_credentials = validate_credentials - self.validation_timeout = validation_timeout - self.opener = opener - - def can_handle(self, conn: Dict[str, Any]) -> bool: - # Uma instancia local carrega uma chave de fachada, entao `hasApiKey` - # sozinho tambem casaria com ela. Como o motor consulta este provider - # antes do LocalProvider, o catalogo local nunca seria descoberto -- e - # por isso que o Ollama local aparecia sem modelo nenhum no painel. - return bool(conn.get("hasApiKey")) and not LocalProvider.is_local_connection(conn) - - def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: - messages = [] - # `modified` decide se vale gravar; `renewed` decide se conta como - # renovacao no resumo do ciclo. Carimbar o horario de uma verificacao - # muda a linha, mas nao renovou credencial nenhuma -- e contar isso - # inflava o "N renovadas" do cron. - modified = False - renewed = False - res = dict(conn) - provider = conn.get("provider", "") - - # 1. Verifica se há chave de API mais recente no host - if self.discovery: - local = self.discovery.get_credential_for_provider(provider) - if local and local.get("apiKey") and local.get("apiKey") != conn.get("apiKey"): - res["apiKey"] = local["apiKey"] - modified = True - renewed = True - src = local.get("source_path", "host") - messages.append(f"Chave de API sincronizada a partir do host ({src})") - - # 2. Pergunta ao provedor se a chave ainda é aceita. Antes daqui a conexão - # era declarada "operacional e ativa" sem nenhuma verificação. - if self.validate_credentials: - record = ConnectionRecord.from_row(res) - result = check_connection( - record, timeout=self.validation_timeout, opener=self.opener - ) - res.update(result.to_dict()) - modified = True - - if result.state == STATE_VALID: - res["testStatus"] = "active" - # Um 4xx que nao seja 401/403 continua provando que a - # autenticacao passou -- a sonda manda corpo vazio de - # proposito, e o provedor so chega a reclamar do corpo depois - # de aceitar a chave. Dizer apenas "aceita (HTTP 400)" fazia a - # tela parecer errada; a frase agora explica o que o numero - # significa. - messages.append( - f"Autenticação aceita pelo provedor ({result.detail})" - if result.detail and "200" in str(result.detail) - else f"Autenticação aceita pelo provedor; a sondagem em si foi recusada ({result.detail})" - ) - elif result.state == STATE_INVALID: - res["testStatus"] = "invalid" - messages.append(f"Chave RECUSADA pelo provedor ({result.detail})") - elif result.state == STATE_RATE_LIMITED: - messages.append(f"Provedor aplicou rate limit na validação ({result.detail})") - elif result.state == STATE_UNREACHABLE: - messages.append(f"Chave não verificada: {result.detail}") - else: - messages.append(result.detail or "Credencial não verificável") - - if not messages: - messages.append("Chave de API inalterada") - - return renewed, res if modified else None, messages - - -# Catalog endpoints, in attempt order: Ollama-native and the OpenAI standard. -MODEL_CATALOG_PATHS = ("/api/tags", "/v1/models", "/models") -PROBE_TIMEOUT_SECONDS = 3.0 - -LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") -LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal") - - -class LocalProvider: - """Health monitor for local OpenAI-compatible instances (Ollama, vLLM, LM Studio).""" - - @staticmethod - def _base_url(conn: Dict[str, Any]) -> str: - especifico = conn.get("providerSpecificData") or {} - return str( - conn.get("baseUrl") - or especifico.get("baseUrl") - or especifico.get("baseURL") - or "" - ) - - @classmethod - def is_local_connection(cls, conn: Dict[str, Any]) -> bool: - """Classificacao de "local" compartilhada, para os providers nao brigarem.""" - provider = str(conn.get("provider", "")).lower() - endereco = cls._base_url(conn) - if endereco: - # Endereco declarado decide sozinho. O marcador "ollama" tambem casa - # com a conta hospedada em https://ollama.com/v1, e trata-la como - # local mandaria o sincronizador sondar um catalogo que nao existe - # ali, alem de tirar a conexao do caminho de validacao de chave. - return any(host in endereco for host in LOCAL_HOSTS) - if any(marker in provider for marker in LOCAL_PROVIDER_MARKERS): - return True - return False - - def can_handle(self, conn: Dict[str, Any]) -> bool: - if self.is_local_connection(conn): - return True - # Sem token e sem chave nao ha o que outro provider faca com a conexao. - return not (conn.get("isOAuth") or conn.get("hasApiKey")) - - def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], str]: - """Query the local instance catalog. Returns (models, error).""" - if not base_url: - return [], "baseUrl not declared on the connection" - - root = base_url.rstrip("/") - # An OpenAI-shaped baseUrl already ends in /v1; the root serves /api/tags. - origin = root[: -len("/v1")] if root.endswith("/v1") else root - last_error = "" - answered = False - - for path in MODEL_CATALOG_PATHS: - target = f"{origin}{path}" if path.startswith("/api") else f"{root}{path}" - try: - req = urllib.request.Request( - target, headers={"User-Agent": "OminiRTKSync-LocalProbe/1.0"} - ) - if api_key: - req.add_header("Authorization", f"Bearer {api_key}") - with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT_SECONDS) as resp: - payload = json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - # The host answered, this path just is not the right one — keep trying. - last_error = str(e) - continue - except (urllib.error.URLError, OSError) as e: - # Nothing is listening: trying the remaining paths only multiplies the - # timeout (3 endpoints x 3s) on every sweep. Give up now. - return [], str(e) - except ValueError as e: - last_error = str(e) - continue - - # Chegar aqui significa resposta HTTP valida e JSON parseavel: o - # servico esta de pe, tendo modelo ou nao. - answered = True - models = self._extract_model_names(payload) - if models: - return models, "" - - if answered: - return [], "" - return [], last_error or "no model returned by the local instance" - - @staticmethod - def _extract_model_names(payload: Any) -> List[str]: - """Extract model names from the Ollama (/api/tags) and OpenAI (/v1/models) shapes.""" - if not isinstance(payload, dict): - return [] - entries = payload.get("models") or payload.get("data") or [] - names = [] - for entry in entries: - if isinstance(entry, str): - names.append(entry) - elif isinstance(entry, dict): - name = entry.get("name") or entry.get("id") or entry.get("model") - if name: - names.append(str(name)) - return names - - def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[str, Any]], List[str]]: - """Sonda a instancia local. - - O primeiro elemento e a contagem de renovacao do ciclo: uma sondagem - nunca renova credencial, entao e sempre False. O dicionario devolvido e - o que deve ser gravado. - """ - models, probe_error = self.discover_models(self._base_url(conn), conn.get("apiKey") or "") - - if models: - return ( - False, - {"discoveredModels": models, "testStatus": "active"}, - [f"Local instance answered with {len(models)} model(s): {', '.join(models[:5])}"], - ) - - # Erro vazio significa que a instancia respondeu com catalogo vazio -- - # instalacao nova, sem modelo baixado. Esta no ar. - if not probe_error: - return ( - False, - {"discoveredModels": [], "testStatus": "active"}, - ["Local instance answered with an empty model catalog"], - ) - - # With no catalog response the connection is not assumed healthy: this is - # exactly the "the local Ollama went down and nobody noticed" case. - return ( - False, - {"testStatus": "unreachable", "lastError": probe_error}, - [f"Local instance did not answer the model catalog: {probe_error}"], - ) diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 5df43bd..454fa5c 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -1,10 +1,26 @@ -"""Renderização server-side do dashboard do OminiRTKSync. +"""Renderização server-side do dashboard. 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 +nunca consulta o gateway: ele recebe a página pronta. Isso mantém a credencial inteiramente do lado do servidor e faz o painel funcionar mesmo com JavaScript desabilitado — o jQuery serve só para conforto. +A casca é a mesma dos projetos irmãos, de propósito: cabeçalho, cartões de +métrica, seletor de idioma, modais e rodapé são idênticos, e o que muda são os +tokens de cor e o CONTEÚDO das tabelas de domínio. + +Os seis cartões existem nos três painéis, sempre, e nesta ordem: conexão com o +gateway, agendador, conexões monitoradas, chaves virtuais, modelos cadastrados +e combos de resiliência. Quando um gateway não tem o conceito, o cartão aparece +com o estado vazio explicando por quê — nunca some da tela. Assimetria entre os +três é pior que um cartão vazio: quem abre as três telas lado a lado precisa +encontrar as mesmas peças no mesmo lugar. + +Todo grid pagina de dez em dez, por `paginacao.py`: o catálogo de um gateway +chega a centenas de modelos, e despejá-los de uma vez faz a tela rolar por +minutos. O contador do cabeçalho do cartão continua mostrando o TOTAL — a +paginação muda o que se vê, não o que existe. + Í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. """ @@ -14,6 +30,25 @@ from typing import Any, Dict, List, Optional from .i18n import DEFAULT_LANGUAGE, LANGUAGES, normalize_language, translate +from .identidade import ( + COR_DO_FAVICON, + GLIFO_DO_FAVICON, + ICONE_DO_PRODUTO, + NOME_DO_PRODUTO, + PALETA, + PROVEDOR_DO_GATEWAY, +) +from .paginacao import POR_PAGINA, recortar, render_paginacao + +# Icone da aba, embutido como data URI: /favicon.ico responde 401 atras do +# Basic Auth, entao um arquivo servido deixaria a aba sem icone ate o +# operador autenticar -- e a pagina de erro nunca teria icone nenhum. +FAVICON = ( + "data:image/svg+xml," + f"" + "" + f"{GLIFO_DO_FAVICON}" +) 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" @@ -28,12 +63,62 @@ 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) +# Papéis cromáticos na ordem em que o `:root` os declara, e o que cada um pinta. +# Os VALORES vêm de identidade.py; a ORDEM e a explicação são comuns aos três +# painéis — é nisto que a casca ser a mesma consiste. +PAPEIS_DO_TEMA = ( + ("--bg", "fundo da pagina"), + ("--surface", "cartao"), + ("--surface-2", "cabecalho de cartao, chip"), + ("--line", "borda"), + ("--accent", "acao primaria"), + ("--accent-2", "acao secundaria, realce"), + ("--brand-a", "marca, inicio do gradiente"), + ("--brand-b", "marca, fim do gradiente"), + ("--text-dim", "texto secundario"), +) + +# Cor do texto: NÃO é papel cromático — vale o mesmo nos três painéis, e por +# isso fica aqui e não na identidade. +COR_DO_TEXTO = "#e6e8ee" + +# Papéis que as páginas servidas antes do login precisam: login e erro têm +# cartão, borda e um botão, e mais nada. +PAPEIS_ANTES_DO_LOGIN = ("--bg", "--surface", "--line", "--accent") + + +def tokens_do_tema(recuo: str = " ") -> str: + """Monta as linhas `--token: valor;` do bloco `:root` do painel.""" + linhas = [ + f"{recuo}{token}:{' ' * max(1, 12 - len(token))}{PALETA[token]}; /* {papel} */" + for token, papel in PAPEIS_DO_TEMA + ] + linhas.append(f"{recuo}--text: {COR_DO_TEXTO};") + return "\n".join(linhas) + + +def tokens_antes_do_login() -> str: + """A fatia do tema que as páginas anteriores ao login usam, em uma linha.""" + valores = " ".join(f"{token}: {PALETA[token]};" for token in PAPEIS_ANTES_DO_LOGIN) + return f"{valores} --text: {COR_DO_TEXTO};" + + +# Estado semântico -> (classe do badge, ícone). O rótulo sai de `health.` +# na hora de desenhar, e nunca fica guardado nesta tabela: um rótulo embutido +# aqui ficaria preso a um idioma e nunca seria traduzido. +# +# A tabela é a UNIÃO dos estados dos três gateways. Um estado que este gateway +# nunca emite não custa nada e mantém a apresentação idêntica nos três; uma +# tabela recortada por produto é como o mesmo estado passou a ser pintado de +# cores diferentes em telas que deveriam ser a mesma. HEALTH_PRESENTATION = { "active": ("text-bg-success", "bi-check-circle-fill"), + "valid": ("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"), + "blocked": ("text-bg-secondary", "bi-slash-circle-fill"), + "over_budget": ("text-bg-danger", "bi-cash-stack"), "no_expiration": ("text-bg-secondary", "bi-infinity"), "unknown": ("text-bg-secondary", "bi-question-circle-fill"), # Estados vindos da validação viva da credencial. @@ -68,10 +153,551 @@ def format_duration(seconds: Optional[int], lang: str = DEFAULT_LANGUAGE) -> str def format_timestamp(value: Optional[str]) -> str: - """Normaliza um timestamp ISO para exibição.""" + """Normaliza um timestamp ISO para exibição. + + Troca APENAS o "T" que separa data de hora, e não todo "T" da string. A + versão anterior fazia `.replace("T", " ")` no texto inteiro, o que a tornava + destrutiva ao ser aplicada duas vezes: a primeira passada produzia + "2026-09-13 19:08:48 UTC", e a segunda comia o "T" de "UTC" e escrevia + "19:08:48 U C" na tela. Um defeito que só aparece quando alguém formata um + valor já formatado -- e isso é fácil de acontecer sem ninguém notar. + """ + if not value: + return "—" + texto = str(value) + if texto.endswith("Z"): + texto = texto[:-1] + " UTC" + # O separador ISO é o "T" na posição 10 (AAAA-MM-DDTHH:MM:SS). + if len(texto) > 10 and texto[10] == "T": + texto = texto[:10] + " " + texto[11:] + return texto + + +def format_timestamp_curto(value: Optional[str]) -> str: + """Data enxuta para a celula da tabela: dia/mes e hora, sem ano nem segundos. + + A forma completa ("2026-09-13 18:40:52 UTC") nao cabe na coluna e era + cortada no meio, o que deixava a informacao pior do que util. O carimbo + inteiro continua no modal de detalhe, a um clique da linha. + """ if not value: return "—" - return str(value).replace("T", " ").replace("Z", " UTC") + try: + momento = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except (ValueError, TypeError): + return format_timestamp(value) + return momento.strftime("%d/%m %H:%M") + + +def rodape_da_pathbit() -> str: + """A assinatura da casa, igual nos três painéis e em TODA tela. + + Fora do catálogo de tradução de propósito: é nome próprio e assinatura de + empresa, não texto de interface -- e a própria linha já mistura as duas + línguas, como no modelo. O ano vem do relógio: um ano escrito à mão + envelhece em silêncio, e ninguém revisa rodapé. + + O coração é `bi-heart-fill`, e não o emoji: o cabeçalho deste módulo fixa + "Bootstrap Icons, nunca emoji", e emoji muda de desenho conforme o sistema. + """ + return f""" +
    + Feito com + pela Pathbit - All rights reserved (c) {datetime.now().year} +
    """ + +def render_notice_page(title: str, body: str, link_label: str = "", + refresh_url: str = "", meta_refresh: 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. + + O refresh instala um ``, e existe para o pouso do + acesso federado: a volta do provedor NAO pode ser um 302 para "/", porque + numa cadeia de redirecionamento iniciada em outro site o navegador nao envia + o cookie `SameSite=Strict` no salto seguinte -- o operador cairia em + "/login" com uma sessao valida no bolso. Um 200 com refresh quebra a cadeia, + e a navegacao seguinte e de primeira parte. + + `refresh_url` recebe o DESTINO; `meta_refresh`, o conteudo bruto do `` + -- a grafia que o `web.py` de um dos irmaos ainda usa. As duas convivem ate + `web.py` convergir, e quem passar as duas ve `refresh_url` ganhar. + """ + link = ( + f'

    {esc(link_label)}

    ' if link_label else "" + ) + conteudo = f"0;url={refresh_url}" if refresh_url else meta_refresh + refresh = ( + f'' if conteudo else "" + ) + return f""" + + + + + + {refresh} + + {esc(title)} + + + + + +
    +
    + +

    {esc(title)}

    +

    {esc(body)}

    + {link} +
    +
    + +""".encode("utf-8") + + +def render_landing_page(lang: str = DEFAULT_LANGUAGE) -> bytes: + """Pouso do retorno do provedor de identidade: "Entrando..." e vai para "/". + + NAO e um 302. Uma cadeia de redirecionamento iniciada em outro site nao + carrega o cookie `SameSite=Strict` no salto seguinte, e o operador cairia na + tela de login com a sessao valida no bolso -- o sintoma pareceria senha + errada. Esta pagina e navegacao nova, e o cookie viaja nela. + + O destino e SEMPRE "/": nenhum parametro da volta vira destino, ou o login + federado viraria um redirecionamento aberto autenticado. + """ + lang = normalize_language(lang) + return f""" + + + + + + + + {NOME_DO_PRODUTO} + + + + + +
    +
    + +

    {esc(translate("sso.landing_title", lang))}

    +

    {esc(translate("sso.landing_body", lang))}

    +

    {esc(translate("auth.updated_link", lang))}

    +
    +
    + {rodape_da_pathbit()} + +""".encode("utf-8") + + +def render_login_page( + lang: str = DEFAULT_LANGUAGE, + erro: str = "", + desafio: str = "", + dificuldade: int = 4, + sso_nome: str = "", + sso_indisponivel: bool = False, + # `sso` e a grafia do irmao cujo `web.py` entrega a configuracao inteira em + # vez do nome ja resolvido. As duas convivem ate `web.py` convergir. + sso: Any = None, +) -> bytes: + """Formulario de entrada, com a mesma casca e a mesma paleta do painel. + + Existe porque o dialogo do Basic Auth e uma janela do NAVEGADOR: nao se + traduz, nao se estiliza, nao oferece logout e nao e HTML -- qualquer + ferramenta que dirija um navegador para no dialogo, porque nao ha nada na + pagina para preencher. Esta pagina resolve os quatro de uma vez. + """ + lang = normalize_language(lang) + # O quinto argumento chega nas duas grafias, e num dos irmaos ele e + # posicional: objeto no lugar de texto e a configuracao, e o nome sai dela. + if sso_nome and not isinstance(sso_nome, str): + sso, sso_nome = sso_nome, "" + if sso is not None and not sso_nome: + sso_nome = sso.nome_do_provedor() if sso.esta_ligado() else "" + aviso = ( + f'' + if erro + else "" + ) + desafio_html = "" + if desafio: + from . import protecao + + detalhes = protecao.detalhes_do_desafio(desafio) + if detalhes: + alvo_nome = translate(f"auth.item_{detalhes['alvo']}", lang) + instrucao = translate("auth.challenge_prompt", lang, item=alvo_nome) + botoes = [] + for item in detalhes["opcoes"]: + icone = protecao.icone_do_item(item) + label = translate(f"auth.item_{item}", lang) + botoes.append( + f'' + ) + grade_botoes = "".join(botoes) + desafio_html = f""" +
    + + + +
    + {grade_botoes} +
    +
    + """ + else: + desafio_html = ( + f'' + f'' + ) + # O botao do SSO e um LINK, nunca um `
    `: a CSP do painel declara + # `form-action 'self'` e o navegador bloqueia, sem erro visivel na tela, a + # submissao que redireciona para fora. Ele fica AO LADO do formulario local, + # que nao sai da tela em configuracao nenhuma -- se o provedor de identidade + # cair, ninguem entraria. + botao_sso = "" + if sso_nome: + botao_sso = ( + f'
    ' + f'
    {esc(translate("sso.or", lang))}' + f'
    ' + f'' + f'' + f'{esc(translate("sso.sign_in_with", lang, provider=sso_nome))}' + ) + elif sso_indisponivel: + # O provedor esta configurado, mas a imagem nao tem a biblioteca dele. + # Dizer isso e melhor do que esconder o botao e deixar a pergunta aberta. + botao_sso = ( + '

    ' + '' + f'{esc(translate("sso.unavailable", lang))}

    ' + ) + return f""" + + + + + + + {NOME_DO_PRODUTO} + + + + + +
    +
    +

    + {NOME_DO_PRODUTO} +

    +

    {esc(translate("auth.login_intro", lang))}

    + {aviso} + +
    + + +
    +
    + + +
    + {desafio_html} + + + {botao_sso} +
    +
    + {rodape_da_pathbit()} + +""".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_credentials_modal(auth_from_env: bool, lang: str) -> str: + """Corpo do modal de troca de credenciais. + + Nenhum valor vem preenchido: um usuário sugerido na tela é uma metade da + credencial entregue de graça a quem abrir a página. + """ + if auth_from_env: + return f""" +
    + +
    {translate("auth.env_managed", lang)}
    +
    """ + return f""" +
    +
    + + +
    +
    + + +
    {esc(translate("password.policy", lang))}
    +
    + +
    """ + + +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 estado_vazio(mensagem: str, dica: str = "") -> str: + """O bloco de estado vazio da familia: icone bi-inbox, uma frase e a dica. + + Os quatro cartoes de tabela usam exatamente este bloco. Cada um deles existe + nos tres paineis por contrato; quando o gateway deste produto nao tem aquele + conceito, ou ainda nao tem dado nenhum, o cartao continua na tela e a frase + diz por que esta vazio AQUI. Assimetria de cartoes e pior que estado vazio. + """ + complemento = f'\n
    {esc(dica)}
    ' if dica else "" + return f""" +
    + + {esc(mensagem)}{complemento} +
    """ + + +def detail_button(modal_id: str, lang: str) -> str: + """Botao (i) da linha, que abre o modal de detalhe daquele item.""" + return f"""""" + + +def render_detail_modal(modal_id: str, titulo: str, linhas: List[tuple], lang: str, + extra: str = "") -> str: + """Modal de detalhe no formato que a familia usa: titulo, pares e um extra. + + O modal e devolvido como bloco solto para ser emitido DEPOIS da tabela: um + `
    ` dentro de `` e HTML invalido, e o navegador o move sozinho + para fora -- o que transforma cada linha da tabela numa surpresa de layout. + """ + corpo = "".join( + f'
    {esc(rotulo)}
    ' + f'
    {valor}
    ' + for rotulo, valor in linhas + ) + return f""" + """ + + +def cabecalho_de_dominio(rows: List[str], lang: str) -> str: + """A casca das tabelas de dominio: SEMPRE as mesmas sete colunas. + + Conexoes, chaves virtuais e modelos sao coisas diferentes lidas do mesmo + jeito -- quem serve, como se chama, de que tipo e, como esta, quanto tempo + resta, quando foi renovado, e o (i) que abre o resto. Uma casca so mantem a + largura das colunas identica entre os cartoes e entre os tres paineis. + """ + 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.details", lang))}
    +
    """ + + +def grid_paginado(nome: str, tabela: str, estado: Dict[str, Any], consulta: Any, + lang: str, detalhes: str = "") -> str: + """Envelope de um grid: a tabela, a barra de paginas e os modais das linhas. + + Todo grid do painel passa por aqui, e e o que garante as dez linhas por + pagina em todos eles -- um grid que nao passasse seria justamente o que + despejaria o catalogo inteiro na tela. + + O `id` do envelope e o destino do link da barra (`#grid-`): sem ele, + trocar de pagina recarrega a tela no topo e o operador perde de vista a + tabela que estava lendo. + + Os modais saem DEPOIS do envelope, e nunca de dentro da tabela: um `
    ` + em `` e HTML invalido, e o navegador o move sozinho para fora. + """ + return f""" +
    {tabela}{render_paginacao(estado, consulta, translate, lang)} +
    """ + detalhes + + +def render_remaining_seconds(remaining: Optional[int], lang: str) -> str: + """Validade restante de um item que nao e conexao (chave virtual, modelo). + + Sem prazo declarado a chave e estatica: vale ate ser desativada, e isso e + "sem expiracao" de verdade -- nao o dado ausente que render_remaining trata + com cautela no caso do OAuth. + """ + if remaining is not None: + return esc(format_duration(remaining, lang)) + return f'{esc(translate("duration.no_expiry_short", lang))}' + + +def render_timestamp_cell(carimbo: Optional[str], lang: str, icone: str) -> str: + """Celula de carimbo de tempo curto, com o valor inteiro guardado no modal.""" + if not carimbo: + return f'{esc(translate("table.never_refreshed", lang))}' + return (f'' + f'{esc(format_timestamp_curto(carimbo))}') def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LANGUAGE) -> str: @@ -134,61 +760,30 @@ def render_last_refresh(conn: Any, lang: str) -> str: icon = '' detail = f'
    {esc(ago)}
    ' if ago else "" - return f'{icon}{esc(format_timestamp(stamp))}{detail}' + return f'{icon}{esc(format_timestamp_curto(stamp))}{detail}' -def render_remaining(conn: Any, lang: str) -> str: +def render_remaining(conn: Any, lang: str, curto: bool = False) -> 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") + 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))}' + ) + # Na celula cabe o fato; a razao ("chave estatica") fica no modal. + chave = "duration.no_expiry_short" if curto else "duration.no_expiry" + return f'{esc(translate(chave, lang))}' def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: @@ -203,14 +798,14 @@ def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: if estado == "bound": pool = conn.egress_binding or "?" return ( - '' + '' f'' f'{esc(translate("egress.bound", lang))}: {esc(pool)}' ) if estado == "shared": if sharing_count > 1: return ( - '' + '' f'' f'{esc(translate("egress.shared", lang, count=sharing_count))}' ) @@ -226,87 +821,216 @@ def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: ) -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 key_state_label(key: Any, lang: str) -> str: + """Por qual caminho a chave foi recusada -- ou que ela segue aceita. + + A celula da tabela diz "recusada" nos tres casos, porque o efeito e o mesmo. + Qual deles foi so cabe aqui, no modal. Um gateway que so tem uma bandeira + cai no ultimo caso e nunca promete os rotulos que nao guarda. + """ + dados = key.data + if dados.get("isBanned"): + return translate("keys.banned", lang) + if dados.get("revokedAt"): + return translate("keys.revoked", lang) + if dados.get("isActive") is False: + return translate("keys.disabled", lang) + return translate("keys.enabled", lang) + + +def render_key_details(key: Any, modal_id: str, lang: str) -> str: + """Modal com o que nao cabe na linha da chave virtual. + + Escopos, modo de acesso, tetos de requisicao e o instante exato de emissao + sao diagnostico: espremidos na tabela empurrariam as colunas uteis para fora + da tela. O TOKEN nunca entra aqui -- ele sequer e lido do banco. + """ + acesso = ( + translate("keys.access_restricted", lang) + if key.model_access_mode == "restricted" + else translate("keys.access_all", lang) ) + escopos = ", ".join(key.scopes) if key.scopes else translate("table.not_declared", lang) + linhas = [ + (translate("table.status", lang), health_badge(key.health_status, lang)), + (translate("table.key_state", lang), esc(key_state_label(key, lang))), + (translate("table.remaining", lang), render_remaining_seconds(key.remaining_seconds, lang)), + (translate("table.issued_at", lang), + f'{esc(format_timestamp(key.issued_at))}'), + (translate("table.last_used", lang), + f'{esc(format_timestamp(key.last_used_at))}'), + (translate("table.model_access", lang), esc(acesso)), + (translate("table.scopes", lang), f'{esc(escopos)}'), + ] + extra = "" + if key.allowed_models: + itens = "".join(f'
  • {esc(m)}
  • ' for m in key.allowed_models) + extra = (f'

    {esc(translate("table.models", lang))}

    ' + f'
      {itens}
    ') + return render_detail_modal(modal_id, key.name, linhas, lang, extra) + + +def render_keys_table(keys: List[Any], lang: str, consulta: Any = None) -> str: + """Chaves virtuais emitidas pelo gateway, uma por linha, nas sete colunas.""" + if not keys: + return estado_vazio(translate("keys.empty", lang)) + + visiveis, estado = recortar(keys, "chaves", consulta) + rows = [] + detalhes = [] + # Id do modal pelo INDICE, nunca pelo nome: nome de chave aceita espaco, + # acento e barra, e nada disso vale como id de elemento HTML. + for indice, key in enumerate(visiveis): + modal_id = f"detalhe-chave-{indice}" + rows.append(f""" + + {esc(PROVEDOR_DO_GATEWAY)} + {esc(key.name)} + + {esc(translate("type.virtual_key", lang))} + + {health_badge(key.health_status, lang)} + {render_remaining_seconds(key.remaining_seconds, lang)} + {render_timestamp_cell(key.issued_at, lang, "bi-clock")} + {detail_button(modal_id, lang)} + """) + detalhes.append(render_key_details(key, modal_id, lang)) + return grid_paginado("chaves", cabecalho_de_dominio(rows, lang), estado, consulta, + lang, "".join(detalhes)) -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 render_model_details(model: Any, modal_id: str, lang: str) -> str: + """Modal com o que nao cabe na linha do modelo. -def metric_card(label: str, value: Any, icon: str, tone: str) -> str: - return f""" -
    -
    -
    -
    - {esc(label)} -
    -
    {esc(value)}
    -
    -
    -
    """ + Os limites de contexto, os endpoints e a descricao sao texto longo; o nome + da conexao dona esta aqui porque e ele que explica de onde vem o status da + linha -- um modelo nao tem saude propria. + """ + nao_declarado = f'{esc(translate("table.not_declared", lang))}' + + def numero(valor: Any) -> str: + return f'{esc(f"{valor:,}".replace(",", " "))}' \ + if isinstance(valor, int) else nao_declarado + + linhas = [ + (translate("table.provider", lang), + f'{esc(model.provider)}' if model.provider else nao_declarado), + (translate("table.connection", lang), + esc(model.connection_name) if model.connection_name else nao_declarado), + (translate("table.status", lang), health_badge(model.health_status, lang)), + (translate("table.source", lang), + f'{esc(model.source)}' if model.source else nao_declarado), + (translate("table.context_limit", lang), numero(model.input_token_limit)), + (translate("table.output_limit", lang), numero(model.output_token_limit)), + (translate("table.endpoints", lang), + f'{esc(", ".join(model.supported_endpoints))}' + if model.supported_endpoints else nao_declarado), + ] + extra = f'

    {esc(translate("models.inherited", lang))}

    ' + if model.description: + extra = (f'

    {esc(translate("table.description", lang))}

    ' + f'

    {esc(model.description)}

    ' + extra) + return render_detail_modal(modal_id, model.id, linhas, lang, extra) + + +def render_models_table(models: List[Any], lang: str, estado_do_catalogo: str = "ok", + consulta: Any = None) -> str: + """Modelos que o gateway publica, nas mesmas sete colunas dos irmaos. + + `estado_do_catalogo` carrega POR QUE a lista veio vazia. Sem isso, "nenhum + modelo" e "nao deu para perguntar" desenham a mesma tela, e o operador vai + procurar um cadastro faltando quando o problema era o gateway nao ter + respondido. + """ + if not models: + motivo = { + "no_key": "models.no_key", + "unreachable": "models.unreachable", + }.get(estado_do_catalogo, "models.empty") + return estado_vazio(translate(motivo, lang)) + + visiveis, estado = recortar(models, "modelos", consulta) + rows = [] + detalhes = [] + for indice, model in enumerate(visiveis): + modal_id = f"detalhe-modelo-{indice}" + provedor = (f'{esc(model.provider)}' + if model.provider else '—') + rows.append(f""" + + {provedor} + {esc(model.id)} + + {esc(translate("type.synced_model", lang))} + + {health_badge(model.health_status, lang)} + {render_remaining_seconds(model.remaining_seconds, lang)} + {render_timestamp_cell(model.last_refresh_at, lang, "bi-arrow-repeat")} + {detail_button(modal_id, lang)} + """) + detalhes.append(render_model_details(model, modal_id, lang)) + return grid_paginado("modelos", cabecalho_de_dominio(rows, lang), estado, consulta, + lang, "".join(detalhes)) -def render_security_banner(is_default_password: bool, lang: str) -> str: - if not is_default_password: - return "" - return f""" - """ +def render_connection_details(conn: Any, refresh_margin: int, sharing_count: int, lang: str) -> str: + """Modal com o que nao cabe na linha da tabela. + + A tabela existe para varrer muitas conexoes de relance; o diagnostico e uma + frase inteira, e espremido entre sete colunas ele sobrepunha a coluna + vizinha. Aqui ele aparece por extenso, junto do resto do estado daquela + conexao, sem competir com nada. + """ + linhas = [ + (translate("table.provider", lang), f'{esc(conn.provider)}'), + (translate("table.status", lang), health_badge(conn.health_status, lang)), + (translate("table.remaining", lang), render_remaining(conn, lang)), + (translate("table.last_refresh", lang), render_last_refresh(conn, lang)), + ] + if conn.is_local and conn.base_url: + linhas.append((translate("table.type", lang), + f'{esc(conn.base_url)}')) + if not conn.is_local: + chip = egress_chip(conn, sharing_count, lang) + if chip: + linhas.append((translate("egress.title", lang), chip)) + + modelos = "" + if conn.is_local and conn.local_models: + itens = "".join(f'
  • {esc(m)}
  • ' for m in conn.local_models) + modelos = (f'

    {esc(translate("table.models", lang))}

    ' + f'
      {itens}
    ') + + extra = ( + f'

    {esc(translate("table.diagnosis", lang))}

    ' + f'

    {esc(render_refresh_reason(conn, refresh_margin, lang))}

    ' + f'{modelos}' + ) + return render_detail_modal(f"detalhe-{conn.id}", conn.name, linhas, lang, extra) -def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: - if not connections: - return f""" -
    - - {esc(translate("connections.empty", lang))} -
    """ - # Uma conta sozinha compartilhando nao e problema: ela e a unica dona - # daquele IP. O alerta comeca na segunda, quando o provedor passa a ver - # identidades distintas na mesma origem. +def render_connections_table(connections: List[Any], refresh_margin: int, lang: str, + consulta: Any = None) -> str: + if not connections: + return estado_vazio(translate("connections.empty", lang), + translate("connections.empty_hint", lang)) + + # Quantas contas de nuvem saem pelo endereço padrão do gateway. Conta-se + # sobre a lista INTEIRA, e não sobre a página: o alerta é sobre quantas + # identidades dividem o endereço, e isso não muda quando se vira a página. + # Uma conta sozinha compartilhando não é problema nenhum -- ela é a única a + # usar aquele IP. O alerta só faz sentido a partir da segunda, que é quando + # o provedor passa a ver identidades distintas na mesma origem. compartilhando = sum( 1 for c in connections if not c.is_local and c.egress_status == "shared" ) + visiveis, estado = recortar(connections, "conexoes", consulta) rows = [] - for c in connections: + detalhes = [] + for c in visiveis: if c.is_local: kind, kind_icon = translate("type.local", lang), "bi-hdd-network" elif c.is_oauth: @@ -332,9 +1056,9 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: if parts: detail = f'
    {" · ".join(parts)}
    ' else: - # Saida de rede: somente leitura. Quem roteia a requisicao e o - # gateway; o painel existe para o operador ver quais contas dividem - # endereco antes que o provedor veja primeiro. + # Saída de rede: somente leitura. Quem roteia a requisição é o + # gateway; o painel existe para que o operador veja quais contas + # dividem endereço antes que o provedor veja primeiro. chip = egress_chip(c, compartilhando, lang) if chip: detail = f'
    {chip}
    ' @@ -347,54 +1071,47 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {esc(kind)} {health_badge(c.health_status, lang)} - {render_remaining(c, lang)} + {render_remaining(c, lang, curto=True)} {render_last_refresh(c, lang)} - {esc(render_refresh_reason(c, refresh_margin, lang))} + {detail_button(f"detalhe-{c.id}", lang)} """) + detalhes.append(render_connection_details(c, refresh_margin, compartilhando, 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))}
    -
    """ + return grid_paginado("conexoes", cabecalho_de_dominio(rows, lang), estado, consulta, + lang, "".join(detalhes)) -def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: +def render_combos_table(combos: List[Dict[str, Any]], lang: str, consulta: Any = None) -> str: + """Combos de resiliencia: o combo e a cascata que ele aciona. + + O tipo do fallback vira um chip ao lado do nome quando o gateway o + classifica. Dois combos com o mesmo modelo principal e cascatas diferentes, + sem dizer por que disparam, confundiriam; um gateway que nao classifica nao + manda a chave, e a linha sai sem chip. + """ if not combos: - return f""" -
    - - {esc(translate("combos.empty", lang))} -
    """ + return estado_vazio(translate("combos.empty", lang), + translate("combos.empty_hint", lang)) + visiveis, estado = recortar(combos, "combos", consulta) rows = [] - for combo in combos: + for combo in visiveis: 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})" + chave_do_tipo = combo.get("kindLabelKey") or "" + chip = (f' {esc(translate(chave_do_tipo, lang))}' + if chave_do_tipo else "") rows.append(f""" - {esc(combo.get("name", "—"))} + {esc(combo.get("name", "—"))}{chip} {esc(preview) or "—"} """) - return f""" + tabela = f"""
    @@ -407,10 +1124,16 @@ def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str:
    """ + return grid_paginado("combos", tabela, estado, consulta, 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.""" + """Lista de execuções do agendador, cada uma com o log do que aconteceu. + + O contador sozinho não distingue "nada a relatar" de "a inspeção falhou". + O log de cada ciclo é o que responde a essa pergunta sem obrigar ninguém a + abrir o arquivo de log do serviço. + """ if not history: return f'

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

    ' @@ -439,10 +1162,7 @@ def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: {esc(format_timestamp(entry.get("timestamp")))} - {esc(translate("cron.result_line", lang, - inspected=entry.get("totalInspected", 0), - refreshed=entry.get("refreshedCount", 0), - duration=entry.get("durationMs", 0)))} + {esc(linha_do_ciclo(entry, lang))}
    @@ -452,10 +1172,55 @@ def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str:
    """) - return f'
    {"".join(items)}
    ' + # Cada ciclo carrega o indice da sua pagina: o script mostra uma de cada + # vez sem tocar no servidor. Dez por pagina, como em todo grid do painel. + por_pagina = POR_PAGINA + total = len(items) + ultima = max(1, (total + por_pagina - 1) // por_pagina) + blocos = [] + for i, item in enumerate(items): + blocos.append(f'
    {item}
    ') + + if ultima == 1: + return f'''
    {"".join(blocos)}
    ''' + + botoes = "".join( + f'''
  • + +
  • ''' + for n in range(1, ultima + 1) + ) + return f'''
    {"".join(blocos)}
    + ''' + + +def linha_do_ciclo(resultado: Dict[str, Any], lang: str) -> str: + """O resumo de um ciclo: quantos foram olhados e o que o ciclo produziu. + + Os dois marcadores viajam juntos porque o trabalho do agendador muda com o + gateway -- um renova credencial, outro relata achado -- e a frase traduzida + usa o marcador do seu produto. `str.format` ignora o que sobra, entao passar + os dois deixa a MESMA chamada correta nos tres paineis; escolher um faria a + contagem do outro aparecer zerada na tela. + """ + renovados = resultado.get("refreshedCount", resultado.get("findingsCount", 0)) + achados = resultado.get("findingsCount", resultado.get("refreshedCount", 0)) + return translate( + "cron.result_line", + lang, + inspected=resultado.get("totalInspected", 0), + refreshed=renovados, + findings=achados, + duration=resultado.get("durationMs", 0), + ) def render_cron_card(cron: Dict[str, Any], lang: str) -> str: + """Cartão do agendador: o estado do ciclo e o resultado da última passada.""" active = bool(cron.get("active")) state_icon = "bi-broadcast text-success" if active else "bi-pause-circle text-secondary" state_text = ( @@ -466,6 +1231,15 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: last = cron.get("lastResult") or {} failed = bool(last) and (not last.get("success", True) or last.get("error")) + # O acumulado do agendador conta uma coisa em cada gateway: um soma + # credenciais renovadas, o outro soma achados da inspecao. Quem diz qual e o + # proprio estado, pelo contador que ele mantem -- rotular pelo produto faria + # a tela prometer um numero que aquele ciclo nunca produz. + if "totalFindings" in cron: + rotulo_do_total, total_do_ciclo = "cron.total_findings", cron.get("totalFindings", 0) + else: + rotulo_do_total, total_do_ciclo = "cron.total_renewals", cron.get("totalRenewals", 0) + return f"""
    @@ -479,8 +1253,8 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: {'!' if failed else ""}
    -
    @@ -490,17 +1264,13 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: {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(translate("cron.next_run", lang))}
    +
    {esc(format_timestamp(cron.get("nextRunAt")))}
    +
    {esc(translate(rotulo_do_total, lang))}
    +
    {esc(total_do_ciclo)}
    +
    {esc(translate("cron.last_result", lang))}
    +
    + {esc(linha_do_ciclo(last, lang) if last else translate("cron.no_runs", lang))} {esc(last.get("error") or "")}
    @@ -509,9 +1279,30 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str: + """Cartão de liveness do gateway. + + Existe para que "o painel está de pé" e "o gateway está de pé" nunca sejam + confundidos: são dois processos distintos, e o painel responde mesmo com o + gateway fora. + + As linhas de banco e de diagnóstico só aparecem quando o estado TRAZ a + bandeira `dbOk`. Um gateway que não guarda banco próprio não tem o que dizer + ali, e desenhar "banco não encontrado" para ele afirmaria uma falha que não + existe. + """ online = bool(gateway.get("online")) tone = "text-success" if online else "text-danger" + icon = "bi-plug-fill" if online else "bi-plug" + codigo = gateway.get("statusCode") + label = ( + (f'ONLINE (HTTP {esc(codigo)})' if codigo else "ONLINE") + 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. + tem_banco = "dbOk" in gateway db_ok = bool(gateway.get("dbOk")) if online and db_ok: diagnosis = translate("gateway.diag_ok", lang) @@ -519,13 +1310,17 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str diagnosis = translate("gateway.diag_db_failed", lang) else: diagnosis = translate("gateway.diag_gateway_failed", lang) - icon = "bi-plug-fill" if online else "bi-plug" - label = ( - f'ONLINE (HTTP {esc(gateway.get("statusCode", "—"))})' - if online - else f'{esc(translate("gateway.offline", lang))} — ' - f'{esc(gateway.get("error") or translate("gateway.no_response", lang))}' - ) + + bloco_do_banco = f""" +
    {esc(translate("gateway.database", lang))}
    +
    + {esc(translate("gateway.db_summary", lang, + connections=gateway.get("dbConnections", 0), + combos=gateway.get("dbCombos", 0)) + if db_ok else translate("gateway.db_missing", lang))} +
    +
    {esc(translate("gateway.diagnostics", lang))}
    +
    {esc(diagnosis)}
    """ if tem_banco else "" return f"""
    @@ -548,55 +1343,208 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str {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)}
    +
    {esc(gateway.get("latencyMs", "—"))} ms
    {bloco_do_banco}
    """ -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") +def campo_de_texto( + nome: str, rotulo: str, valor: str, ajuda: str = "", tipo: str = "text", + travado: bool = False, marcador: str = "", +) -> str: + """Um campo do formulario de SSO, com rotulo traduzido e ajuda opcional.""" + dica = f'
    {esc(ajuda)}
    ' if ajuda else "" return f""" -
    - -
    {esc(flash.get("message", ""))}
    -
    """ +
    + + + {dica} +
    """ + + +def render_sso_modal( + config: Dict[str, str], + lang: str, + tem_segredo: bool = False, + segredo_do_ambiente: bool = False, + desligado_pelo_ambiente: bool = False, + endereco_de_retorno: str = "", +) -> str: + """Corpo do modal da entrada federada, com as duas abas. + + A casca do modal (titulo, botao de fechar) e comum aos tres paineis e mora + em `render_dashboard`; daqui sai so o CORPO, que e a parte presa ao + formulario que `web.py` sabe receber. + + O segredo do cliente NUNCA volta para ca: o campo nasce vazio, a tela diz + apenas se existe um guardado, e salvar em branco MANTEM o anterior. Um GET + de configuracao que devolvesse o valor seria o mesmo que publica-lo no HTML. + """ + ativo = config.get("enabled") or "" + aviso_ambiente = ( + f'' + if desligado_pelo_ambiente + else "" + ) + + if segredo_do_ambiente: + estado_do_segredo = translate("sso.secret_from_env", lang) + elif tem_segredo: + estado_do_segredo = translate("sso.secret_stored", lang) + else: + estado_do_segredo = translate("sso.secret_missing", lang) + + aba_oidc = "".join([ + campo_de_texto("base_url", translate("sso.base_url", lang), config.get("base_url", ""), + translate("sso.base_url_help", lang), marcador="https://painel.exemplo.com"), + f""" +
    + +
    {esc(endereco_de_retorno or "-")}
    +
    """, + campo_de_texto("oidc_issuer", translate("sso.issuer", lang), config.get("oidc_issuer", ""), + marcador="https://accounts.google.com"), + campo_de_texto("oidc_client_id", translate("sso.client_id", lang), + config.get("oidc_client_id", "")), + campo_de_texto("oidc_client_secret", translate("sso.client_secret", lang), "", + estado_do_segredo, tipo="password", + travado=segredo_do_ambiente, + marcador="••••••••" if tem_segredo else ""), + campo_de_texto("oidc_scopes", translate("sso.scopes", lang), + config.get("oidc_scopes", ""), marcador="openid email profile"), + ]) + + aba_saml = "".join([ + f""" +
    + +
    {esc(translate("sso.saml_unavailable", lang))}
    +
    """, + campo_de_texto("saml_idp_entity_id", translate("sso.idp_entity_id", lang), + config.get("saml_idp_entity_id", ""), travado=True), + campo_de_texto("saml_idp_sso_url", translate("sso.idp_sso_url", lang), + config.get("saml_idp_sso_url", ""), travado=True), + campo_de_texto("saml_idp_cert", translate("sso.idp_cert", lang), + config.get("saml_idp_cert", ""), travado=True), + ]) + + return f""" + {aviso_ambiente} +

    {esc(translate("sso.intro", lang))}

    +
    + +
    +
    {aba_oidc} +
    +
    {aba_saml} +
    +
    + +
    + {campo_de_texto("allowed_domains", translate("sso.allowed_domains", lang), + config.get("allowed_domains", ""), marcador="empresa.com,filial.com")} + {campo_de_texto("allowed_emails", translate("sso.allowed_emails", lang), + config.get("allowed_emails", ""), + translate("sso.allowlist_help", lang), marcador="chefe@empresa.com")} + +
    + + +
    + +
    + + +
    {esc(translate("sso.local_password_help", lang))}
    +
    + +

    {esc(translate("sso.logout_note", lang))}

    + + +
    """ 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, + # Os seis cartoes, na ordem do contrato. Todos opcionais na assinatura: quem + # chama sem um deles -- um teste, um script -- continua desenhando a pagina, + # com o cartao no estado vazio, que e o comportamento correto. + connections: Optional[List[Any]] = None, + combos: Optional[List[Dict[str, Any]]] = None, + keys: Optional[List[Any]] = None, + models: Optional[List[Any]] = None, + cron: Optional[Dict[str, Any]] = None, + gateway: Optional[Dict[str, Any]] = None, + db_path: str = "", + router_url: str = "", + current_user: str = "", + is_default_password: bool = False, + refresh_margin: int = 900, auth_from_env: bool = False, flash: Optional[Dict[str, str]] = None, lang: str = DEFAULT_LANGUAGE, + # A query da requisicao, de onde sai a pagina de cada grid (`?pag_modelos=2`). + # Ausente, todo grid abre na primeira pagina -- que e o que acontece hoje, + # enquanto `web.py` ainda nao repassa a query. + consulta: Optional[Dict[str, List[str]]] = None, + # Estado que so um dos gateways produz. Chega por nome para que a MESMA + # assinatura sirva aos tres `web.py`; o que este painel nao usa fica em + # branco e nao aparece na tela. + models_state: str = "ok", + model_states: Optional[Dict[str, str]] = None, + team_aliases: Optional[Dict[str, str]] = None, + findings: Optional[List[Dict[str, Any]]] = None, + counters: Optional[Dict[str, Any]] = None, + # `proxy` e a grafia com que o `web.py` de um dos irmaos chama o gateway. As + # duas apontam para o mesmo estado, e a segunda sai quando `web.py` convergir. + proxy: Optional[Dict[str, Any]] = None, + # Estado do SSO para a tela de configuracao, nas tres grafias que os + # `web.py` ainda usam. Todas opcionais: sem nenhuma, o modal aparece no + # estado desligado, que e o correto de um painel sem SSO configurado. + sso_view: Optional[Dict[str, Any]] = None, + sso: Any = None, + sso_config: Optional[Dict[str, str]] = None, + sso_tem_segredo: bool = False, + sso_segredo_do_ambiente: bool = False, + sso_desligado_pelo_ambiente: bool = False, + sso_endereco_de_retorno: str = "", ) -> str: """Monta a página completa do dashboard, já com todos os dados embutidos.""" lang = normalize_language(lang) + connections = connections or [] + combos = combos or [] + keys = keys or [] + models = models or [] + cron = cron or {} + counters = counters or {} + findings = findings or [] + model_states = model_states or {} + gateway = gateway or proxy or {} + router_url = router_url or gateway.get("url") or "" + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") 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"), @@ -605,32 +1553,13 @@ def render_dashboard( 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))}
    -
    - -
    """ - ) + tabela_de_conexoes = render_connections_table(connections, refresh_margin, lang, consulta) + tabela_de_chaves = render_keys_table(keys, lang, consulta) + tabela_de_modelos = render_models_table(models, lang, models_state, consulta) + tabela_de_combos = render_combos_table(combos, lang, consulta) + corpo_do_sso = render_sso_modal(sso_config or {}, lang, sso_tem_segredo, + sso_segredo_do_ambiente, sso_desligado_pelo_ambiente, + sso_endereco_de_retorno) return f""" @@ -638,7 +1567,8 @@ def render_dashboard( - OminiRTKSync + + {NOME_DO_PRODUTO} @@ -648,21 +1578,12 @@ def render_dashboard( @@ -706,9 +1689,9 @@ def render_dashboard(
    - +
    -

    OminiRTKSync

    +

    {NOME_DO_PRODUTO}

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

    @@ -716,18 +1699,18 @@ def render_dashboard(
    {render_language_switcher(lang)} -
    -
    - -
    -
    @@ -736,6 +1719,9 @@ def render_dashboard(
    {metrics}
    +
    {render_gateway_card(gateway, db_path, lang)}
    {render_cron_card(cron, lang)}
    @@ -748,26 +1734,54 @@ def render_dashboard( {len(connections)}
    - {render_connections_table(connections, refresh_margin, lang)} + {tabela_de_conexoes} +
    + +
    +
    + + {esc(translate("keys.title", lang))} + + {len(keys)} +
    + {tabela_de_chaves} +
    + +
    +
    + + {esc(translate("models.title", lang))} + + {len(models)} +
    + {tabela_de_modelos}
    -
    - {esc(translate("combos.title", lang))} +
    + + {esc(translate("combos.title", lang))} + + {len(combos)}
    - {render_combos_table(combos, lang)} + {tabela_de_combos}
    {esc(translate("footer.signed_in", lang))} {esc(current_user)} + {esc(translate("footer.generated", lang))} {esc(generated_at)}
    + {rodape_da_pathbit()}
    + +