diff --git a/.env.example b/.env.example index 1ec29fa..e02326c 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ DB_PATH=/app/data/db/data.sqlite # 2. 9Router Gateway Connectivity # ------------------------------------------------------------------------------ # Base URL of the 9Router gateway for diagnostic checks and integration -ROUTER_URL=http://127.0.0.1:20128 +ROUTER_URL=http://9rtk-router:20128 # ------------------------------------------------------------------------------ # 3. Synchronization and Cron Scheduler Parameters @@ -89,7 +89,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 router-sync cat /app/data/db/.dashboard_recovery +# docker exec 9rtk-sync cat /app/data/db/.dashboard_recovery # Depois defina a sua senha pela tela. # # Com DASHBOARD_PASSWORD preenchida, o ambiente vira a fonte da verdade e a @@ -103,11 +103,33 @@ DASHBOARD_PASSWORD= # quando e necessaria. # DASHBOARD_RECOVERY_HASH= +# ------------------------------------------------------------------------------ +# ENTRADA FEDERADA (SSO) / SINGLE SIGN-ON +# ------------------------------------------------------------------------------ +# O SSO e OPCIONAL e nasce desligado: sem configuracao, o painel funciona +# exatamente como hoje. O resto da configuracao (emissor, identificador do +# cliente, lista de quem pode entrar) e feito pela tela, no botao Configuracoes. +# +# Single sign-on is OPTIONAL and starts off. Everything but the secret below is +# configured from the screen, under the Settings button. + +# Segredo do cliente OIDC. O ambiente VENCE o arquivo gravado pela tela: com +# esta variavel preenchida, o campo da tela fica travado. Vazia aqui de +# proposito -- um valor publicado num arquivo de exemplo e uma credencial +# publica. Deixe-a vazia para administrar o segredo pela tela, que o grava em +# .sso_client_secret com modo 0600. +OIDC_CLIENT_SECRET= + +# Interruptor de emergencia. Com 1, o SSO fica desligado mesmo com tudo +# configurado, sem tocar no banco -- e o que devolve o formulario local quando o +# provedor de identidade cai e o painel esta atras de um tunel. +# SSO_DISABLED=0 + # ------------------------------------------------------------------------------ # Persistent file log # ------------------------------------------------------------------------------ # Directory for log files. Default: /logs. -LOG_DIR=/app/data/logs +LOG_DIR=/app/logs # Retention in days before rotated files are purged (default: 30). LOG_RETENTION_DAYS=30 @@ -138,3 +160,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 8edf62a..e1d6740 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/9rtksyncatest ficava sem imagem. +# estivesse puxando ghcr.io/pathbit/9rtksync 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..dfb3f24 100644 --- a/.gitignore +++ b/.gitignore @@ -1231,6 +1231,10 @@ temp/ # Credenciais do painel: o hash de recuperacao e a senha em texto herdada. .dashboard_recovery .dashboard_auth.json +# Segredo do cliente OIDC, gravado com modo 0600. Mesma classe de arquivo e +# mesmo motivo: numa execucao local o diretorio de dados pode cair dentro da +# arvore do repositorio. +.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 d1b95af..7eb8658 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 6583aa2..a9397cb 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,19 @@ -.PHONY: setup test test-container venv run status docker-build docker-run clean +.PHONY: rede-de-inferencia setup test test-container venv run status docker-build docker-run clean # Cria o .env a partir do .env.example. Nunca sobrescreve um .env existente: # ele carrega os seus segredos, e um `make setup` distraido nao pode apaga-los. # O docker compose le esse .env sozinho, por estar ao lado do compose. -setup: +# 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 \ @@ -52,8 +62,13 @@ status: docker-build: docker build -t 9rtksync:latest -t ghcr.io/pathbit/9rtksync: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 9rtksync -p 9091:9090 9rtksync:latest + docker run --rm -it --name 9rtk-sync -p 127.0.0.1:9091:9090 9rtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/README.md b/README.md index 034286a..2178b2f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ 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 router-sync cat /app/data/db/.dashboard_recovery +docker exec 9rtk-sync cat /app/data/db/.dashboard_recovery ``` Sign in as `admin` with that value, then set a real password on the screen. The @@ -83,46 +83,46 @@ need it. ## Running with Docker -### Configuração: `.env` a partir do exemplo +### Configuration: `.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 a `.env` +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 .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. +At the end, the target lists exactly which variables were left blank and need +filling in. Fill them and 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** committed, 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 +side of the mapping in the compose file — the right side is the internal port, +the one the process listens on. Official multi-architecture Docker images (`linux/amd64` and `linux/arm64`) are published automatically to the GitHub Container Registry (GHCR): @@ -136,15 +136,32 @@ docker pull ghcr.io/pathbit/9rtksync:latest Add `9rtksync` to your `docker-compose.yml` alongside [9Router](https://github.com/decolua/9router): ```yaml +name: 9rtksync-stack + services: - 9router: + 9rtk-router: image: decolua/9router:latest - container_name: claudegravity-router + container_name: 9rtk-router + hostname: 9rtk-router + networks: + - 9rtksync-net restart: unless-stopped ports: # 20128 dentro do container; 8081 no host, para nao disputar a porta # padrao do 9Router com a stack do artigo. - "127.0.0.1:8081:20128" + environment: + - DATA_DIR=/app/data + - PORT=20128 + - HOSTNAME=0.0.0.0 + # Sem esta linha o fluxo de login e redirecionado para a porta interna, + # que nao existe no host. + - NEXT_PUBLIC_BASE_URL=http://localhost:8081 + - 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. + - INITIAL_PASSWORD=${INITIAL_PASSWORD:?defina INITIAL_PASSWORD no .env} + - JWT_SECRET=${JWT_SECRET:?defina JWT_SECRET no .env (openssl rand -hex 32)} volumes: - 9router_data:/app/data healthcheck: @@ -154,27 +171,35 @@ services: retries: 3 start_period: 20s - 9rtksync: + 9rtk-sync: + # Mesmo uid do gateway: os dois compartilham o volume de dados. + user: "1000:1000" image: ghcr.io/pathbit/9rtksync:latest - container_name: 9rtksync + container_name: 9rtk-sync + hostname: 9rtk-sync + networks: + - 9rtksync-net restart: unless-stopped ports: - "127.0.0.1:9091:9090" volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro + - 9rtksync_logs:/app/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/db/data.sqlite - - ROUTER_URL=http://9router:20128 + - ROUTER_URL=${ROUTER_URL:-http://9rtk-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: - 9router: + 9rtk-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)"] @@ -185,6 +210,14 @@ services: volumes: 9router_data: + 9rtksync_logs: + +networks: + 9rtksync-net: + name: 9rtksync-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. ``` --- @@ -257,10 +290,18 @@ When running with `ENABLE_WEB_DASHBOARD=1`, access the dashboard in your browser Dashboard capabilities: * Live 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. * Real-time countdown meters with visual health badges for every connection. -* Gateway diagnostic card with millisecond latency testing (`POST /api/test-gateway`). -* Password change modal for credential rotation (`POST /api/change-password`). -* Manual sync trigger via REST API (`POST /api/sync` and `POST /api/cron-run`). +* Gateway diagnostic card with millisecond latency testing. +* Password change modal for credential rotation. +* Manual sync trigger, from the panel or from a script. + +The buttons on the panel post to `/acoes/…` and answer with a redirect +(POST-Redirect-GET), so a reload never repeats the action. The `/api/…` routes +(`POST /api/test-gateway`, `/api/change-password`, `/api/sync`, `/api/cron-run`) +do the same work for `curl` and for monitoring, and they answer JSON. Both exist +on purpose; naming only the API here read as if the buttons used it, which they +do not. --- @@ -279,8 +320,10 @@ The only requirement is Docker. Nothing else needs to be installed on your machi # Or via Makefile target make test-container -# Or via Docker Compose -docker compose -f docker-compose.test.yml run --rm test +# docker-compose.test.yml nao tem servico de teste: e uma bancada viva +# (gateway real + este sincronizador) para conferir a stack de ponta a ponta. +docker compose -f docker-compose.test.yml up -d +docker compose -f docker-compose.test.yml down -v ``` ### Option 2. Local Virtual Environment (Optional Prerequisites) diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index c7a596b..352f5ff 100644 --- a/docker-compose.egress-test.yml +++ b/docker-compose.egress-test.yml @@ -1,4 +1,4 @@ -name: egress-test +name: 9rtk-egress # Bancada para testar POR ONDE o trafego sai. # @@ -21,7 +21,7 @@ services: # se o vinculo por conta realmente separa as saidas. proxy-a: image: ubuntu/squid:latest - container_name: egress-proxy-a + container_name: 9rtk-proxy-a restart: unless-stopped ports: - "127.0.0.1:18081:3128" @@ -39,7 +39,7 @@ services: proxy-b: image: ubuntu/squid:latest - container_name: egress-proxy-b + container_name: 9rtk-proxy-b restart: unless-stopped ports: - "127.0.0.1:18082:3128" @@ -60,7 +60,7 @@ services: # direto da maquina. echo: image: python:3.12-alpine - container_name: egress-echo + container_name: 9rtk-echo restart: unless-stopped ports: - "127.0.0.1:18080:8080" @@ -100,6 +100,9 @@ services: networks: egress: + # Nome declarado, e nao derivado do nome do projeto: sem isto a rede nasce + # como `9rtk-egress_egress` e o endereco depende do diretorio. + name: 9rtk-egress-net ipam: config: - subnet: 172.31.0.0/24 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 82a95fc..c9cca45 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,9 +1,16 @@ name: 9rtksync-stack services: - 9router: + 9rtk-router: image: decolua/9router:latest - container_name: 9router + container_name: 9rtk-router + hostname: 9rtk-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 `9rtk-router`. + - 9rtksync-net + - rtk-inference-net restart: unless-stopped ports: # Porta interna 20128 (padrao do 9Router); publicada em 8081 no host. @@ -14,7 +21,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:8081 - 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 @@ -28,9 +35,18 @@ services: volumes: - 9router_data:/app/data - 9rtksync: + 9rtk-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 9router -- 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/9rtksync:latest - container_name: 9rtksync + container_name: 9rtk-sync + hostname: 9rtk-sync + networks: + - 9rtksync-net restart: unless-stopped ports: # Porta interna 9090 (igual no OminiRTKSync); publicada em 9091 no host. @@ -39,11 +55,11 @@ services: volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro - - 9rtksync_logs:/app/data/logs + - 9rtksync_logs:/app/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/db/data.sqlite - - ROUTER_URL=${ROUTER_URL:-http://9router:20128} + - ROUTER_URL=${ROUTER_URL:-http://9rtk-router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - CRON_ENABLED=${CRON_ENABLED:-1} @@ -61,11 +77,16 @@ 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} + # Entrada federada (SSO). Vazia = o segredo do cliente e administrado pela + # tela e gravado com modo 0600; preenchida = o ambiente vence e a tela + # trava o campo. SSO_DISABLED=1 desliga o SSO sem tocar no banco. + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + - SSO_DISABLED=${SSO_DISABLED:-0} + - LOG_DIR=${LOG_DIR:-/app/logs} - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - - 9router + - 9rtk-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 @@ -73,6 +94,102 @@ services: 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"). + 9rtk-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: 9rtk-tunel + hostname: 9rtk-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://9rtk-router:20128} + networks: + - 9rtksync-net + depends_on: + - 9rtk-router + + 9rtk-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: 9rtk-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://9rtk:9090). Com + # `9rtk-tailnet` viraria http://9rtk-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: 9rtk + 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: + - 9rtk_tailnet:/var/lib/tailscale + networks: + - 9rtksync-net + volumes: + 9rtk_tailnet: 9router_data: 9rtksync_logs: + +networks: + 9rtksync-net: + name: 9rtksync-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 9RTKSync 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://9rtk-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 0ffbecb..f8a8bdc 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,9 +12,12 @@ name: 9rtksync-test services: - 9router: + 9rtk-test-router: image: decolua/9router:latest - container_name: 9rtksync-test-gateway + container_name: 9rtk-test-router + hostname: 9rtk-test-router + networks: + - 9rtksync-test-net restart: unless-stopped ports: - "127.0.0.1:19128:20128" @@ -43,10 +46,13 @@ services: # O primeiro boot roda migracoes e cria o banco. start_period: 45s - 9rtksync: + 9rtk-test-sync: build: . image: ghcr.io/pathbit/9rtksync:local - container_name: 9rtksync-test-sync + container_name: 9rtk-test-sync + hostname: 9rtk-test-sync + networks: + - 9rtksync-test-net restart: unless-stopped ports: # Porta interna 9090 nos tres sincronizadores; publicada em 19091 aqui. @@ -54,7 +60,7 @@ services: environment: - DATA_DIR=/app/data/db - DB_PATH=/app/data/db/data.sqlite - - ROUTER_URL=http://9router:20128 + - ROUTER_URL=http://9rtk-test-router:20128 - WEB_PORT=9090 - SYNC_INTERVAL=60 - REFRESH_MARGIN=900 @@ -66,7 +72,7 @@ services: volumes: - gateway_data:/app/data depends_on: - 9router: + 9rtk-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)"] @@ -77,3 +83,10 @@ services: volumes: gateway_data: + +networks: + 9rtksync-test-net: + name: 9rtksync-test-net + # Rede propria tambem na bancada de teste. 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. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 4c08c2d..48ddf82 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -31,14 +31,15 @@ state the gateway keeps about its own connections. | `cli.py` | Argument parsing, bootstrap of logging and the recovery hash, entry points. | | `daemon.py` | `SyncEngine.sync_all()` — one full pass over every connection. | | `cron.py` | Background scheduler; keeps per-cycle history with the actions each produced. | -| `database.py` | SQLite reads and writes against `providerConnections` and `combos`. | +| `gateway.py` | Everything that knows what THIS gateway stores and where: SQLite reads and writes against `providerConnections` and `combos`, the HTTP model catalogue, and the `carregar_painel()` seam. | +| `identidade.py` | The only file that may differ from the sibling panels: name, gateway, palette, icon, ports, cookie names. | | `models.py` | `ConnectionRecord` and its derived properties (`is_oauth`, `is_local`, `remaining_seconds`, `health_status`). | | `normalizer.py` | Credential-format self-healing and stale-lock removal. | | `discovery.py` | Finds provider credentials on the host filesystem. | | `providers/` | One handler per credential family: Google, generic OAuth, API key, local. | | `combos.py` | Keeps the fallback combos registered and up to date. | -| `web/server.py` | HTTP server, routing, actions. | -| `web/render.py` | Server-side HTML rendering. | +| `web.py` | HTTP server, routing, actions. | +| `render.py` | Server-side HTML rendering. | | `i18n.py`, `prefs.py` | Interface language and its SQLite persistence. | | `auth.py`, `logs.py` | Credential rules and the persistent file log. | diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md index f4d837a..bbf2751 100644 --- a/docs/wiki/Authentication.md +++ b/docs/wiki/Authentication.md @@ -92,8 +92,8 @@ password. **Retrieving it later** ```bash -docker logs 9rtksync 2>&1 | grep "Recovery hash" -docker exec 9rtksync cat /app/data/.dashboard_recovery +docker logs 9rtk-sync 2>&1 | grep "Recovery hash" +docker exec 9rtk-sync cat /app/data/.dashboard_recovery ``` **Notes** diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 1bdcf92..dbd3628 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. | @@ -74,6 +74,15 @@ back to the dashboard. Full rules in [Authentication](Authentication). +**Single sign-on.** Two variables, and everything else is configured from the screen: + +| Variable | Default | Meaning | +| :--- | :--- | :--- | +| `OIDC_CLIENT_SECRET` | *(empty)* | Client secret of the OIDC application. Set here, the environment wins over the file the screen writes, and the screen locks the field. Left empty, the panel stores the secret in `.sso_client_secret` with mode `0600`, next to the recovery credential. | +| `SSO_DISABLED` | `0` | `1` switches single sign-on off without touching the database — the emergency way back in when the identity provider is down. | + +Full rules in [Single Sign-On](Single-Sign-On). + --- ## Logging @@ -109,11 +118,11 @@ No dashboard, no interactive setup, scheduler on a one-minute cadence, logs kept ```yaml environment: - DB_PATH=/app/data/db/data.sqlite - - ROUTER_URL=http://9router:20128 + - ROUTER_URL=http://9rtk-router:20128 - SYNC_INTERVAL=60 - REFRESH_MARGIN=1200 - ENABLE_WEB_DASHBOARD=0 - - LOG_DIR=/app/data/logs + - LOG_DIR=/app/logs - LOG_RETENTION_DAYS=90 - LOG_TO_STDOUT=0 # The image ships a HEALTHCHECK that probes /healthz, which only the dashboard diff --git a/docs/wiki/Dashboard.md b/docs/wiki/Dashboard.md index 7f6d32e..6e797a7 100644 --- a/docs/wiki/Dashboard.md +++ b/docs/wiki/Dashboard.md @@ -15,10 +15,23 @@ Reachable at **http://localhost:9091** (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, last renewal, and a **details** button that opens the per-connection modal. | +| Virtual keys | The keys the gateway itself issued (`apiKeys`), with the issue date and whether the gateway still accepts them. The key material is never read and never drawn. | +| Registered models | The catalogue the gateway publishes on `/v1/models`, minus the combos. Status, remaining validity and last renewal are **inherited** from the connection that serves each model — a model has no health of its own. | | Resilience combos | Registered combos and their model cascade. | +The six cards below the metrics are the same six, in the same order, in all three +RTKSync panels. When this gateway has no data for one of them, the card stays on +screen with an empty state saying why — an absent card would make two panels look +like two different products. + +The model catalogue is the one card that comes from HTTP rather than from SQLite: +9Router assembles `/v1/models` at request time, from its static provider registry +plus the live connections, so there is no table to read. The read is authenticated +with a key the gateway itself issued; with no active key, the card says so instead +of claiming an empty catalogue. + --- ## Refreshing @@ -29,9 +42,9 @@ 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** | Runs one full scheduler cycle (`POST /acoes/cron`), then reports what changed. | | **Test connection** | Invalidates the 30 s probe cache and really calls the gateway. | +| **Settings** | Opens the single sign-on screen (`POST /acoes/sso`), which asks for the current panel password on top of the session. See [Single Sign-On](Single-Sign-On). | The page is served with `Cache-Control: no-store, must-revalidate`, so a browser reload always hits the server. @@ -40,8 +53,10 @@ hits the server. ## Renewal diagnosis -The single most useful column. Previously the panel showed only `0 renewed`, with no way to tell -"nothing needed renewing" from "renewal failed". Now each connection carries the reason: +It lives in the **details modal** of each connection, opened by the button at the end of the row. +It used to be a table column, but a whole sentence squeezed between seven columns overlapped its +neighbour. 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 ea1a33a..ff24469 100644 --- a/docs/wiki/Egress-Testing.md +++ b/docs/wiki/Egress-Testing.md @@ -25,9 +25,9 @@ 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 | +| `9rtk-proxy-a` | `172.31.0.11` | an HTTP proxy | +| `9rtk-proxy-b` | `172.31.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | +| `9rtk-echo` | `172.31.0.20` | the referee: answers with the source address it saw | The referee is what makes this verifiable. It returns JSON: @@ -57,7 +57,7 @@ the request: ```bash # 1. put the gateway on the bench network -docker network connect egress-test_egress +docker network connect 9rtk-egress-net # 2. register the pool through the gateway's own API curl -s -X POST http://127.0.0.1:8081/api/proxy-pools \ @@ -65,7 +65,7 @@ curl -s -X POST http://127.0.0.1:8081/api/proxy-pools \ -d '{"name":"bench","proxyUrl":"http://172.31.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 9rtk-proxy-a ``` A line like `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the @@ -78,7 +78,7 @@ gateway fell back to direct.** Against a running 9Router (read on 2026-09-12): -- the pool binding works: `docker logs egress-proxy-a` showed +- the pool binding works: `docker logs 9rtk-proxy-a` showed `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.31.0.2` is the gateway's container; - the **pool test path** detects a dead proxy correctly: @@ -134,7 +134,7 @@ 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 9rtk-proxy-a`. Uma linha como `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o endereço do container do gateway passando pelo proxy — o vínculo funciona. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 3702d7c..9cd08ff 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -19,11 +19,13 @@ republishes these pages automatically. Editing a page directly here will be over | [Configuration](Configuration) | Every environment variable — the full headless contract | | [Dashboard](Dashboard) | The server-rendered panel, language switcher, cron logs | | [Authentication](Authentication) | Credentials, headless mode, break-glass recovery | +| [Single Sign-On](Single-Sign-On) | Optional OIDC sign-in beside the local form, and how to get back in when the provider is down | | [Logging](Logging) | Persistent file log, rotation, 30-day retention | | [Architecture](Architecture) | How the sync engine talks to the 9Router database | | [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, and which field answers it | | [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 +86,7 @@ credential on first boot — read it and sign in as `admin`, then set a real password on the screen: ```bash -docker exec router-sync cat /app/data/db/.dashboard_recovery +docker exec 9rtk-sync cat /app/data/db/.dashboard_recovery ``` Full detail in [Authentication](Authentication). diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index cadd218..d82dd5a 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -15,12 +15,15 @@ docker pull ghcr.io/pathbit/9rtksync:latest A working `docker-compose.yml` alongside the gateway: ```yaml -name: 9router-stack +name: 9rtksync-stack services: - 9router: + 9rtk-router: image: decolua/9router:latest - container_name: 9router + container_name: 9rtk-router + hostname: 9rtk-router + networks: + - 9rtksync-net restart: unless-stopped ports: # 20128 dentro do container; 8081 no host, para nao disputar a porta @@ -30,12 +33,23 @@ services: - DATA_DIR=/app/data - PORT=20128 - HOSTNAME=0.0.0.0 + # Without this line the login flow is redirected to the internal port, + # which does not exist on the host. + - NEXT_PUBLIC_BASE_URL=http://localhost:8081 volumes: - 9router_data:/app/data - 9rtksync: + 9rtk-sync: + # Same uid as the gateway. Both share the volume, and this service creates + # db/ on startup: as root those directories are born root-owned and + # 9router -- which runs as `node` (1000) -- loses write access to its own + # volume ("EACCES: permission denied, mkdir '/app/data/db/backups'"). + user: "1000:1000" image: ghcr.io/pathbit/9rtksync:latest - container_name: 9rtksync + container_name: 9rtk-sync + hostname: 9rtk-sync + networks: + - 9rtksync-net restart: unless-stopped ports: # Internal port 9090 (same in OminiRTKSync); published on 9091. @@ -44,20 +58,20 @@ services: volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro - - 9rtksync_logs:/app/data/logs + - 9rtksync_logs:/app/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/db/data.sqlite - - ROUTER_URL=http://9router:20128 + - ROUTER_URL=http://9rtk-router:20128 - SYNC_INTERVAL=300 - REFRESH_MARGIN=900 - WEB_PORT=9090 - DASHBOARD_USER=admin - DASHBOARD_PASSWORD=change-me - - LOG_DIR=/app/data/logs + - LOG_DIR=/app/logs - LOG_RETENTION_DAYS=30 depends_on: - - 9router + - 9rtk-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 @@ -68,6 +82,13 @@ services: volumes: 9router_data: 9rtksync_logs: + +networks: + 9rtksync-net: + name: 9rtksync-net + # The stack gets its own network. On the default network two stacks on the + # same daemon resolve the same short name, and there is no telling which + # gateway the synchronizer connected to. ``` Then open **http://localhost:9091**. @@ -138,8 +159,8 @@ Or with no local install at all: ## Upgrading ```bash -docker compose pull 9rtksync -docker compose up -d 9rtksync +docker compose pull 9rtk-sync +docker compose up -d 9rtk-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..104358a --- /dev/null +++ b/docs/wiki/Licensing-And-Capacity.md @@ -0,0 +1,788 @@ +# Licensing and capacity: how many subscriptions for how many developers + +*(Versão em português ao final.)* + +"We are twelve developers — how many Max subscriptions do I buy?" is the first +question anyone asks after the gateway is up, and it is the one this wiki cannot +close with a table. Not because nobody did the arithmetic: because **no consumer +subscription publishes its absolute capacity**. Anthropic publishes a multiplier +and a window; OpenAI publishes ranges and then says they are not fixed; Google +points at a dashboard. One number in the whole landscape comes stamped with an +absolute value, and it is stamped *per user*. + +So this page gives three things instead of a table of licences: the formula, the +demand side solved with numbers that were actually measured, and the command that +finds the missing term in your environment. And it starts with the part that +needs no measurement at all. + +--- + +## The part that needs no measuring + +For Claude Pro/Max, `L = N`. Twelve developers, twelve subscriptions, each one +bought and authenticated by its own holder. That is not a capacity finding — it +is the licence text +`[FONTE: https://code.claude.com/docs/en/legal-and-compliance — read on 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 (…)" + +> "**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." + +A gateway does not reduce that number, and this one does not try to. What it +does is keep accounts that **are already individual** alive, readable and +observable: renewing tokens before they die, healing credential formats the +gateway cannot parse, clearing locks that have already expired, and showing which +account is actually blocked. That is where it pays for itself — not in buying +fewer seats. + +For Google AI Pro / Antigravity and for OpenAI plans the equivalent terms +**were not read here**: `[A VERIFICAR: read each provider's subscription terms and +cite URL + date, as was done for Anthropic above]`. Do not assume symmetry +between providers. + +--- + +## What is published, and what is a hole + +| Provider | 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 plans also have a weekly usage limit that applies across all models." | **No** — a relative multiplier and a window. | +| OpenAI Codex | Message estimates "per five-hour period", as plan ranges (Plus 10–100 / 25–200 / 250–2,000 depending on the model) | **No** — "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** — it defers to the dashboard. | +| 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 — read on 2026-09-12]` +`[FONTE: https://learn.chatgpt.com/docs/pricing — read on 2026-09-12]` +`[FONTE: https://ai.google.dev/gemini-api/docs/rate-limits — read on 2026-09-12]` +`[FONTE: https://docs.cloud.google.com/gemini/docs/quotas — read on 2026-09-12]` + +The last row is the instructive one. The moment a provider does publish a hard +number, it arrives carved *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 detail of +wording — it is the shape of the answer, and it is the same shape the licence +text imposes above. + +So one term stays empty on purpose: + +`[A MEDIR]` **`C_window` — the capacity of one subscription inside its reset +window.** Nobody publishes it. The procedure to obtain it is below; until you +run it, any table of the form "1 licence = 4 devs" is invention. + +--- + +## The formula + +| Symbol | Meaning | Where it comes from | +| :--- | :--- | :--- | +| `N` | developers on the team | headcount | +| `c` | concurrency factor, 0–1 | `[A MEDIR]` — the fraction of `N` requesting at the same moment | +| `U_sim` | simultaneous active sessions | `U_sim = N × c` | +| `R_h` | requests per hour per active session | measured — below | +| `T_tot` | **total** input tokens per request (cache reads included) | measured — below | +| `T_out` | output tokens per request | measured — below | +| `W_h` | quota reset window, in hours | 5 h published for Anthropic `[FONTE: support.claude.com article 11049741 — read on 2026-09-12]`; the 2 h seen on Antigravity is a **log observation**, not a published window `[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L689 — read on 2026-09-12]` | +| `F` | slack | an operating decision; 0.30 in the examples below | +| `C_window` | capacity of **one** subscription inside `W_h` | `[A MEDIR]` | + +``` +U_sim = N × c +D = U_sim × R_h × (T_tot + T_out) × W_h × (1 + F) +L = ceil( D / C_window ) +``` + +**The unit of `D` and the unit of `C_window` must be the same.** On this gateway +the path is a subscription, and a subscription's meter does not publish what it +counts — so both sides are kept in **total** tokens, cache reads included. The +"only uncached input counts toward ITPM" rule belongs to the **API** rate-limit +page, not to a Pro/Max meter, and it is not a rounding difference: on the history +measured below the total input median is **22.6×** the median of the input that +would count for ITPM (88,442 ÷ 3,914 — a ratio between two medians, good for the +order of magnitude and not for accounting). Importing that rule here would size +the team at a twentieth of its demand, which is the most likely silent error in +the whole method. If your team is on API keys instead of +subscriptions, that is the sibling gateway's page, not this one — see +[Where this gateway differs](#where-this-gateway-differs). + +--- + +## The demand side, measured + +This is a **snapshot of one machine, frozen at an instant** — not a constant. +The history under `~/.claude/projects` grows with every session, so a run +without a cutoff gives a different answer every day. The cutoff is what makes +the block below reproducible rather than merely plausible: + +```bash +python3 tools/measure_agent_usage.py --until 2026-09-13T02:00:00Z +``` + +`[FONTE: the command above, run on 2026-09-12 on the author's machine — the +cutoff 2026-09-13T02:00:00Z is 23:00 local time, UTC−3. It +reproduces bit for bit on that machine for as long as Claude Code keeps the +session files of that period; on yours it will print your numbers, not these.]` + +``` +recorte (--since) : nenhum (desde o inicio) +recorte (--until) : 2026-09-13T02:00:00Z +sessoes analisadas : 131 +linhas assistant com usage (antes do dedup) : 17382 +turnos unicos (dedup por message.id) : 7332 +inflacao de contar linha em vez de id : 2.37x +T_in entrada que conta p/ ITPM mediana : 3914 +T_in entrada que conta p/ ITPM p90 : 7668 +T_out saida mediana : 723 +T_out saida p90 : 1351 +T_cache leitura de cache mediana : 83463 +T_tot entrada total (conta+cache) mediana : 88442 +T_tot entrada total (conta+cache) p90 : 205437 +R_h requisicoes por hora ativa mediana : 206 +R_h requisicoes por hora ativa p90 : 342 +fracao de leitura de cache no total : 98.2% +razao entrada total / entrada que conta : 22.6x +pico de sessoes simultaneas : 13 + +TOTAL GERAL entrada total (conta+cache) : 1967956629 +TOTAL GERAL saida : 5817008 +TOTAL GERAL token total do periodo : 1973773637 +``` + +Three caveats that have to travel with those numbers: + +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. Over the population above — the lines + that pass every filter the script applies (`type: "assistant"`, a `usage` + object with a positive token count, a parsable timestamp, inside the cutoff) — + counting lines instead of ids inflates the count by **2.37×**: 17,382 lines + collapse to 7,332 turns. That factor is printed by the script itself, on the + `inflacao` line, so it is not a claim you have to take on faith. Any + consumption figure derived from Claude Code history without that dedup is + wrong by roughly a factor of two. +2. **`R_h ≈ 206 req/h` is an agent session**, roughly one request every 17 s — + not a person typing. The machine measured runs orchestration with subagents, + which is also why "13 simultaneous sessions" is one operator's parallelism and + not a team's concurrency. +3. These are **this** machine's numbers, and the point of publishing them is the + order of magnitude and the method, not the digits. Run the script on yours: + `python3 tools/measure_agent_usage.py` (no cutoff: your whole history). It + reads only the numeric fields of `usage`, the `message.id` and the timestamp — + no conversation content is read, aggregated or printed. + +--- + +## Sizing table + +`W_h = 5 h`, in **total** tokens, from the measured profile above. + +`c = 0.6` and slack `F = 0.30` are **arbitrated, not measured** — there is no +measurement of concurrency anywhere on this page, and `c` is still marked +`[A MEDIR]` in the table of symbols. They are defaults picked inside +`tools/sizing.py` so the formula has something to resolve; the script is the +place where they were *chosen*, not evidence for them. Vary them there and +measure your own. +`[FONTE: tools/sizing.py, run on 2026-09-12 — it computes D from the +measured profile; c and F are its own hardcoded constants]` + +| Profile | Developers | `U_sim` | Demand `D` inside the 5 h window | Licences | +| :--- | ---: | ---: | ---: | :--- | +| median | 3 | 1.8 | 214,905,483 tokens | `ceil(D / C_window)` | +| median | 12 | 7.2 | 859,621,932 tokens | `ceil(D / C_window)` | +| median | 40 | 24.0 | 2,865,406,440 tokens | `ceil(D / C_window)` | +| p90 | 3 | 1.8 | 827,441,503 tokens | `ceil(D / C_window)` | +| p90 | 12 | 7.2 | 3,309,766,013 tokens | `ceil(D / C_window)` | +| p90 | 40 | 24.0 | 11,032,553,376 tokens | `ceil(D / C_window)` | + +The right-hand column is deliberately unresolved. This is exactly where a method +differs from an invented table: it names what is missing, in which unit, and how +to get it. + +Two readings that survive the missing term, because they are ratios: + +- **All the uncertainty lives in `c`, not in `N`.** `D` is a product, and + `U_sim = N × c`, so the two are perfectly symmetric: doubling `N` and doubling + `c` move `D` by exactly the same amount. That symmetry is the point. `N` you + know exactly — you count chairs — while `c` is a guess that can easily be off + by 2×, and a 2× error in `c` is a 2× error in the answer. Measuring + concurrency is where the effort pays, not because the term is stronger, but + because it is the only one still unknown. +- **The p90 profile is about 3.85× the median** (827,441,503 ÷ 214,905,483 at any + team size, since `N` cancels). Sizing for the median and discovering the p90 in + production is the usual way this goes wrong. + +### Filling in `C_window` + +The only place a subscription's capacity is visible is the usage screen of the +provider's own account, which shows the progress bars for the 5 h and weekly +windows `[FONTE: https://support.claude.com/en/articles/11049741-what-is-the-max-plan — read on 2026-09-12]`. + +1. Wait for the window to reset and note the time. +2. Work a typical shift inside the window. +3. Read the fraction `p` consumed on the bar, and cut the history to exactly that + period. `D_measured` is the `TOTAL GERAL token total do periodo` line: + + ```bash + python3 tools/measure_agent_usage.py --since 2026-09-12T21:00:00Z --until 2026-09-13T02:00:00Z + ``` + + On the machine measured above, that 5 h window totals `433,749,323` tokens. +4. `C_window ≈ D_measured / p`, in total tokens. + +Note that step 3 needs the **sum** over the window, not the medians — the medians +above describe one average request, and multiplying them back out is not the same +number. That is why the script prints both. + +That is a measurement with a stated procedure, not a guess — and it is valid for +*that* plan, *that* model and *that* effort level, because the provider's own +page says all four factors move the result. + +--- + +## What this synchronizer shows you about it + +This is where the page stops being arithmetic. The gateway knows nothing about +requests per minute or tokens per month; what it records is **the fact that a +ceiling was reached**, and that record is the only evidence that closes the loop. + +### The one field that answers the capacity question + +`rateLimitedUntil`, inside the JSON `data` column of `providerConnections`. It is +**a deadline, not a flag** — it holds the instant the provider's window reopens +`[FONTE: src/nine_rtksync/models.py:173-186]`. Beside it live the +`modelLock_*` keys: locks scoped to **one model family, not the whole account** +`[FONTE: src/nine_rtksync/normalizer.py:91-99]`. + +That distinction is the whole capacity story on this gateway. An account is +rarely "out"; one family is. Measured during a block, model by model: the +`ag/gemini-*` entries answered 503 while `ag/claude-opus-4-6-thinking` (3.8 s), +`ag/claude-sonnet-4-6` (1.4 s) and `ag/gpt-oss-120b-medium` (0.8 s) kept +answering on the same account +`[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L684-L720 — read on 2026-09-12]`. + +### And the trap on the screen + +The **Status** badge does not answer the capacity question, and it is worth being +explicit about that rather than letting someone discover it during an outage. +`health_status` consults `rate_limit_active` only on the API-key branch; an OAuth +connection is classified by how much life is left in its token +`[FONTE: src/nine_rtksync/models.py:189-227]`. So an Antigravity account +holding a rate-limit deadline 90 minutes into the future, plus a family lock, +reports: + +``` +is_oauth : True +rate_limit_active : True +health_status : active +``` + +The badge reads **Active**, and it is not lying: the credential is healthy. It +is answering a different question. The live probe cannot rescue it either — for +an OAuth connection the probe asks Google's `tokeninfo` about the *token* +`[FONTE: src/nine_rtksync/credential_check.py:238-255]`, and the `rate_limited` +state exists only for the HTTP 429 an API-key probe can receive +`[FONTE: src/nine_rtksync/credential_check.py:115-116]`. A quota-exhausted +account has a perfectly live token. + +See [Dashboard](Dashboard) for what each badge does mean. + +### Where the answer actually is + +**1. The database.** Validated against a synthetic database with the real schema +`[FONTE: src/nine_rtksync/gateway.py:64 — the providerConnections columns]`, +run on 2026-09-12: + +```bash +sqlite3 -header -column ~/.9router/data/db/data.sqlite " +SELECT name AS account, + provider, + CASE WHEN json_extract(data,'\$.rateLimitedUntil') IS NULL THEN '-' + ELSE datetime(json_extract(data,'\$.rateLimitedUntil')/1000,'unixepoch') END AS account_hold_until, + (SELECT count(*) FROM json_each(providerConnections.data) + WHERE key LIKE 'modelLock_%') AS family_locks +FROM providerConnections ORDER BY provider, name;" +``` + +``` +account provider account_hold_until family_locks +------- ----------- ------------------- ------------ +dev-a antigravity 2026-09-13 01:05:27 1 +dev-b antigravity - 0 +dev-c antigravity - 2 +``` + +`dev-a` is held at the account level. `dev-c` is not held at all — it simply lost +two model families and is still serving everything else. Reading that as "two +accounts down" would buy a licence that was never needed. + +**2. The scheduler log.** Every time a deadline expires, the sweep clears it and +says so in a note that reaches both the **Logs** modal on the scheduler card and +the persistent file log `[FONTE: src/nine_rtksync/daemon.py:129-135]`. The two +notes are emitted verbatim by the normalizer +`[FONTE: src/nine_rtksync/normalizer.py:89 and :99]`, the `[HEAL]` prefix is what +`log_msg` puts in front of them `[FONTE: src/nine_rtksync/daemon.py:37]`, and the +timestamp and level in front of that come from the handler's formatter, +`"[%(asctime)s] [%(levelname)s] %(message)s"` +`[FONTE: src/nine_rtksync/logs.py:90-91]`. That exact text is what makes the +greps below match at all: + +``` +[2026-09-12 18:04:11] [INFO] [HEAL] [antigravity · dev-a] Expired rateLimitedUntil lock successfully cleared +[2026-09-12 18:04:11] [INFO] [HEAL] [antigravity · dev-a] Temporary model lock modelLock_gemini-3.8-flash-high expired and removed +``` + +Which turns the file log into the counter nobody else keeps. The file is +`9rtksync.log` and retention is 30 days by default — `DEFAULT_RETENTION_DAYS = 30`, +overridable by `LOG_RETENTION_DAYS` +`[FONTE: src/nine_rtksync/logs.py:24-25 and 31-34]` — so the week of history the +rule below needs is always there (see [Logging](Logging); the shipped stack points +`LOG_DIR` at `/app/logs`): + +```bash +grep -c "rateLimitedUntil lock successfully cleared" /app/logs/9rtksync.log +grep -o "modelLock_[a-z0-9.-]*" /app/logs/9rtksync.log | sort | uniq -c | sort -rn +``` + +**3. The state, without SQL.** `9rtksync --status` prints every connection with +its provider, type, health and remaining validity, plus the registered combos and +their cascades, and `/api/status` returns the state as JSON — with `healthStatus`, +`expiresAtMs` and `remainingSeconds` per connection +`[FONTE: src/nine_rtksync/web.py:549-561]`. Note what is **not** in that +payload: neither `rateLimitedUntil` nor `modelLock_*` is exported. For the +capacity question, use the SQL above or the log. + +**The decision rule.** Count locks per account per day for a week. An account +that locks every day is under-provisioned; an account that never locks is slack +that can absorb another person. That is the only evidence that closes the +sizing; everything before it is projection. + +--- + +## The cheapest lever: a cascade that crosses families + +Because a lock is scoped to a model family, a fallback cascade that **changes +family** buys capacity without buying a licence. The synchronizer registers two +such combos by default, visible on the panel under **Resilience combos** with +their **Model cascade** `[FONTE: src/nine_rtksync/combos.py:14-36; the two labels are combos.title and +table.cascade in src/nine_rtksync/i18n.py:84-85]`: + +``` +claudegravity-fallback ag/gemini-3.8-flash-high → ag/gemini-3.7-flash-high → + ag/gemini-3.6-flash-high → ag/claude-sonnet-4-6 → + ag/gpt-oss-120b-medium +``` + +Read that cascade against the measurement above and the honest reading is +uncomfortable: the **first three rungs fall together** — they were all 503 during +the same block — so the capacity is bought by rungs four and five, the ones that +leave the Gemini family. A cascade of five models inside one family is one model +with extra steps. + +And the illusion that costs the most money, measured rather than argued: +**registering the same account twice does not double anything.** Two entries in +the gateway, one ceiling +`[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L716-L720 — read on 2026-09-12]`. +`L` counts **distinct accounts with their own subscription**, which is the same +number the licence text already forced. + +--- + +## Renewal is not quota + +Two different clocks, and confusing them produces the wrong diagnosis — the +wrong purchase, in this case. + +| | Credential validity | Quota | +| :--- | :--- | :--- | +| Duration | ~1 h — the renewal takes the provider's `expires_in`, default 3599 s `[FONTE: src/nine_rtksync/providers/google.py:202]` | 5 h / weekly published; 2 h per family is a log observation `[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L689 — read on 2026-09-12]` | +| Symptom | 401, "spontaneous" disconnection | 429 / 503 | +| Field | `expiresAt` | `rateLimitedUntil`, `modelLock_*` | +| Who fixes it | automatic renewal — what this service does | wait for the window, or one more licence | +| Scales with the team? | **No** | **Yes** | + +The "it logs itself out after an hour" that sends people shopping for more +accounts is a storage format, not a missing quota: 9Router writes `expiresAt` as +an ISO string where its own consumer expects epoch milliseconds, and the +normalizer converts it on every sweep +`[FONTE: src/nine_rtksync/normalizer.py:56-66]`. Neither clock belongs in the +formula, but only one of them is solved by money. + +--- + +## Where this gateway differs + +| | 9Router (this repo) | A LiteLLM proxy | +| :--- | :--- | :--- | +| What is multiplexed | subscription accounts over OAuth | virtual keys over an API credential you already own | +| What "licence" means | one subscription, one holder | nothing — it means tier and budget | +| Where the ceiling lives | inside the provider's account, invisible | declared at three levels, verifiable | +| `C_window` | `[A MEDIR]` — not published | the question does not arise: the API publishes a **per-minute** ceiling (RPM / ITPM / OTPM per tier), not a window capacity `[FONTE: https://platform.claude.com/docs/en/api/rate-limits — read on 2026-09-12]` | +| Granularity | per account **and per model family** | per key / team / platform default | +| Unit of `D` | total tokens | uncached input tokens | +| How it grows | buy an account, with a new holder | move up a tier, redistribute budget | +| Saturation signal | `rateLimitedUntil`, `modelLock_*` in SQLite | 429 plus the proxy's own spend log | + +The sibling [OminiRTkSync](https://github.com/pathbit/OminiRTkSync) sizes exactly +like this column — same shape, same `[A MEDIR]`, different storage format for +the expiry. The LiteLLM-facing sibling is the one where the question changes +shape entirely: there the ceiling is declared at **three** levels and the rule is +`key ≤ team ≤ platform default`, checkable field by field — which is exactly the +sibling's subject, because LiteLLM accepts a key that declares more than its +team and then silently enforces the smaller number +`[FONTE: https://github.com/pathbit/LiteLlmRTKSync/blob/master/docs/wiki/Rate-Limit-Coherence.md — lido em 2026-09-13]`. Do not carry a +number from one column to the other. + +--- + +## One more constraint that is not about capacity + +Several sessions on one account are unremarkable. What draws attention is the +inverse — several **accounts** leaving through one address, which is the natural +shape of a gateway with everyone's account registered in it. Sizing a team up +walks straight into that, so read +[Egress and Multi-Session](Egress-And-Multi-Session) before you add the tenth +account, and note its trap: an inactive pool, or one with no address, raises no +error — the connection silently falls back to the host's address. + +--- + +## Reproducing everything on this page + +```bash +python3 tools/measure_agent_usage.py # the demand side, on your machine +python3 tools/sizing.py # the tables (it prints C_window as C_janela) +9rtksync --status # current state of every account +``` + +To reproduce the exact block published above rather than measure your own, add +the cutoff it was taken at — otherwise the growing history gives a larger number +every day: + +```bash +python3 tools/measure_agent_usage.py --until 2026-09-13T02:00:00Z +``` + +Numbers without a reproducible script become, given enough time, invented +numbers. That is why both scripts are versioned in this repository, next to this +page — `tools/measure_agent_usage.py` and `tools/sizing.py` — and why the +measured block above carries the exact `--until` cutoff it was produced with. + +--- + +# Em português + +"Somos doze devs, quantas assinaturas Max eu compro?" é a primeira pergunta +depois que o gateway sobe, e é a que esta wiki não fecha com uma tabela. Não por +falta de conta: porque **nenhuma assinatura de consumo publica a capacidade +absoluta dela**. A Anthropic publica multiplicador e janela; a OpenAI publica +faixas e em seguida diz que não são fixas; o Google remete ao painel. Um único +número do quadro inteiro vem com valor absoluto — e vem carimbado *por usuário*. + +## A parte que não depende de medir nada + +Para Claude Pro/Max, `L = N`. Doze devs, doze assinaturas, cada uma comprada e +autenticada pelo próprio titular. Isso não é achado de capacidade, é o texto da +licença +`[FONTE: https://code.claude.com/docs/en/legal-and-compliance — lido em 12/09/2026]`: + +> "**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." + +O gateway não reduz esse número, e este aqui não tenta. O que ele faz é manter +vivas, legíveis e observáveis contas que **já são individuais**. É aí que ele se +paga — não em comprar menos assinatura. + +Para Google AI Pro / Antigravity e para planos OpenAI, os termos equivalentes +**não foram lidos aqui**: `[A VERIFICAR: ler os termos de cada fornecedor e citar +URL + data, como foi feito com a Anthropic]`. Não presuma simetria. + +## O buraco honesto + +O Gemini Code Assist é a única linha do quadro com número fechado — **1.500 +requisições por usuário por dia** no Standard, **2.000** no Enterprise, **2** por +segundo por usuário +`[FONTE: https://docs.cloud.google.com/gemini/docs/quotas — lido em 12/09/2026]`. +E repare na forma: não existe um pote de 1.500 que doze devs dividem; existem +doze potes de 1.500. É a mesma forma que a licença impõe acima. + +Então um termo fica vazio de propósito: `[A MEDIR]` **`C_window`, a capacidade de +uma assinatura dentro da janela de reset.** Ninguém publica. Enquanto ele não for +medido, qualquer tabela "1 licença = 4 devs" é invenção. + +## A fórmula e a tabela + +``` +U_sim = N × c +D = U_sim × R_h × (T_tot + T_out) × W_h × (1 + F) +L = ceil( D / C_window ) +``` + +O perfil abaixo é um **instantâneo de uma máquina, congelado num instante** — não +uma constante. O histórico em `~/.claude/projects` cresce a cada sessão, então +rodar sem recorte dá outro número a cada dia; o recorte é o que torna o bloco +reproduzível em vez de apenas plausível: + +```bash +python3 tools/measure_agent_usage.py --until 2026-09-13T02:00:00Z +``` + +`[FONTE: o comando acima, executado em 12/09/2026 na máquina do autor — o +recorte 2026-09-13T02:00:00Z é 23:00 no fuso local, UTC−3. Reproduz +bit a bit naquela máquina enquanto o Claude Code guardar os arquivos daquele +período; na sua ele vai imprimir os seus números, não estes.]` + +Dali saem 131 sessões e 7.332 turnos **deduplicados por `message.id`** — contar +linha em vez de `id` infla a contagem em **2,37×** (17.382 linhas viram 7.332 +turnos), e é o próprio script que imprime esse fator, na linha `inflacao`, sobre +a mesma população que ele usa no resto do bloco: as linhas `type: "assistant"` +com objeto `usage` de contagem positiva, timestamp legível e dentro do recorte. +`R_h` mediana 206 req/h, `T_tot` mediana 88.442 tokens, `T_out` mediana 723. +Trate `R_h` como **sessão de agente**, uma requisição a cada ~17 s, não como um +dev digitando; rode o script no seu ambiente. + +**A unidade dos dois lados da divisão tem que ser a mesma.** Aqui o caminho é +assinatura, e o medidor da assinatura não publica o que conta — então `D` e +`C_window` ficam os dois em **token total**, leitura de cache inclusa. A regra +"só entrada não-cacheada conta para o ITPM" é da página de rate limits **da +API**, não do medidor de um Pro/Max, e importá-la para cá não é diferença de +arredondamento: no histórico medido a mediana da entrada total é **22,6×** a +mediana da entrada que contaria para ITPM (88.442 ÷ 3.914 — razão entre duas +medianas, serve para ordem de grandeza e não para contabilidade). Quem mistura +as duas dimensiona o time por um vigésimo da demanda dele. + +`W_h = 5 h`, em **token total**, sobre o perfil medido acima. + +`c = 0,6` e folga `F = 0,30` são **arbitrados, não medidos** — não há medição de +concorrência nenhuma nesta página, e `c` continua sendo um `[A MEDIR]` declarado. +São apenas os valores escolhidos dentro de `tools/sizing.py` para +que a fórmula tenha o que resolver: o script é onde eles foram *arbitrados*, não +evidência a favor deles. Varie os dois lá e meça os seus. +`[FONTE: tools/sizing.py, executado em 12/09/2026 — ele calcula D a partir do +perfil medido; c e F são constantes dele mesmo]` + +| Perfil | Devs | `U_sim` | Demanda `D` na janela de 5 h | Licenças | +| :--- | ---: | ---: | ---: | :--- | +| mediana | 3 | 1,8 | 214.905.483 tokens | `ceil(D / C_window)` | +| mediana | 12 | 7,2 | 859.621.932 tokens | `ceil(D / C_window)` | +| mediana | 40 | 24,0 | 2.865.406.440 tokens | `ceil(D / C_window)` | +| p90 | 3 | 1,8 | 827.441.503 tokens | `ceil(D / C_window)` | +| p90 | 12 | 7,2 | 3.309.766.013 tokens | `ceil(D / C_window)` | +| p90 | 40 | 24,0 | 11.032.553.376 tokens | `ceil(D / C_window)` | + +A coluna da direita fica em aberto de propósito. É exatamente aí que método se +separa de tabela inventada: ele diz o que falta, em que unidade e como obter. + +Duas leituras sobrevivem ao termo faltante, porque são razões. A primeira: **toda +a incerteza está em `c`, não em `N`.** `D` é um produto e `U_sim = N × c`, então +os dois são perfeitamente simétricos — dobrar `N` e dobrar `c` deslocam `D` +exatamente igual. É justamente essa simetria que importa: `N` você sabe de cor, +é contar cadeira, enquanto `c` é um palpite que erra 2× com facilidade — e 2× de +erro em `c` é 2× de erro na resposta. Medir concorrência compensa não porque o +termo pese mais, mas porque é o único que ainda está desconhecido. A segunda: o +**p90 é cerca de 3,85× a mediana** (827.441.503 ÷ 214.905.483, em qualquer +tamanho de time, já que `N` se cancela). + +Para preencher `C_window`: espere a janela zerar, trabalhe uma jornada típica, +leia a fração `p` consumida na tela de uso do fornecedor e recorte o histórico +exatamente naquele período. `D_medido` é a linha `TOTAL GERAL token total do +periodo`: + +```bash +python3 tools/measure_agent_usage.py --since 2026-09-12T21:00:00Z --until 2026-09-13T02:00:00Z +``` + +Na máquina medida acima essa janela de 5 h soma `433.749.323` tokens. Daí +`C_window ≈ D_medido / p`, em token total. É a **soma** do período que entra +aqui, não as medianas: mediana descreve uma requisição média, e remultiplicá-la +não devolve o mesmo número — por isso o script imprime as duas coisas. Vale para +*aquele* plano, *aquele* modelo e *aquele* nível de esforço. + +## O que este sincronizador te mostra sobre isso + +O gateway não sabe nada de requisição por minuto. O que ele guarda é **o registro +de que o teto foi atingido**, e esse registro é a única evidência que fecha a +conta. + +O campo é `rateLimitedUntil`, dentro da coluna JSON `data` de +`providerConnections`. Ele é **um prazo, não uma bandeira**: guarda o instante em +que a janela do provedor reabre `[FONTE: src/nine_rtksync/models.py:173-186]`. Ao +lado dele ficam as chaves `modelLock_*`, travas por **família de modelo, não pela +conta inteira** `[FONTE: src/nine_rtksync/normalizer.py:91-99]`. Durante um +bloqueio medido, `ag/gemini-*` devolvia 503 enquanto `ag/claude-opus-4-6-thinking` +(3,8 s), `ag/claude-sonnet-4-6` (1,4 s) e `ag/gpt-oss-120b-medium` (0,8 s) +seguiam respondendo na mesma conta +`[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L684-L720 — lido em 12/09/2026]`. + +**E a armadilha da tela.** O badge **Status** não responde à pergunta de +capacidade. `health_status` consulta `rate_limit_active` só no ramo de chave de +API; conexão OAuth é classificada pelo que resta de vida no token +`[FONTE: src/nine_rtksync/models.py:189-227]`. Uma conta Antigravity com prazo de +rate limit 90 minutos à frente e uma trava de família reporta: + +``` +is_oauth : True +rate_limit_active : True +health_status : active +``` + +O badge diz **Active** e não está mentindo — a credencial está saudável. Ele +responde outra pergunta. A sondagem também não salva: para OAuth ela pergunta ao +`tokeninfo` do Google sobre o *token* +`[FONTE: src/nine_rtksync/credential_check.py:238-255]`, e o estado +`rate_limited` só existe para o 429 que uma sondagem de chave recebe +`[FONTE: src/nine_rtksync/credential_check.py:115-116]`. Conta sem cota tem token +vivo. Veja [Dashboard](Dashboard) para o que cada badge de fato significa. + +**Onde a resposta está.** No banco, com a consulta validada contra um banco +sintético de mesmo esquema (12/09/2026): + +```bash +sqlite3 -header -column ~/.9router/data/db/data.sqlite " +SELECT name AS account, + provider, + CASE WHEN json_extract(data,'\$.rateLimitedUntil') IS NULL THEN '-' + ELSE datetime(json_extract(data,'\$.rateLimitedUntil')/1000,'unixepoch') END AS account_hold_until, + (SELECT count(*) FROM json_each(providerConnections.data) + WHERE key LIKE 'modelLock_%') AS family_locks +FROM providerConnections ORDER BY provider, name;" +``` + +``` +account provider account_hold_until family_locks +------- ----------- ------------------- ------------ +dev-a antigravity 2026-09-13 01:05:27 1 +dev-b antigravity - 0 +dev-c antigravity - 2 +``` + +`dev-a` está travada no nível da conta. `dev-c` não está travada: perdeu duas +famílias e continua servindo o resto. Ler isso como "duas contas fora" compra +licença que não faltava. + +E no log: cada prazo vencido é limpo e anunciado numa nota que chega ao modal +**Logs** do agendador e ao log em arquivo +`[FONTE: src/nine_rtksync/daemon.py:129-135]`. As duas notas saem literalmente do +normalizador `[FONTE: src/nine_rtksync/normalizer.py:89 e :99]`, o prefixo +`[HEAL]` é o que o `log_msg` põe na frente delas +`[FONTE: src/nine_rtksync/daemon.py:37]`, e a data e o nível que vêm antes disso +são do formatador do handler, `"[%(asctime)s] [%(levelname)s] %(message)s"` +`[FONTE: src/nine_rtksync/logs.py:90-91]`. É esse texto exato que faz os `grep` +abaixo casarem. O arquivo é o `9rtksync.log`, com +retenção de 30 dias por padrão — `DEFAULT_RETENTION_DAYS = 30`, ajustável por +`LOG_RETENTION_DAYS` `[FONTE: src/nine_rtksync/logs.py:24-25 e 31-34]` — o que dá +a semana de histórico que a regra abaixo pede (veja [Logging](Logging); a stack de +exemplo aponta `LOG_DIR` para `/app/logs`): + +```bash +grep -c "rateLimitedUntil lock successfully cleared" /app/logs/9rtksync.log +grep -o "modelLock_[a-z0-9.-]*" /app/logs/9rtksync.log | sort | uniq -c | sort -rn +``` + +Sem SQL, `9rtksync --status` imprime cada conexão com provedor, tipo, saúde e +validade restante, mais os combos e suas cascatas, e `/api/status` devolve +`healthStatus`, `expiresAtMs` e `remainingSeconds` +`[FONTE: src/nine_rtksync/web.py:549-561]` — mas repare no que **não** vai +nesse payload: nem `rateLimitedUntil` nem `modelLock_*`. Para capacidade, use o +SQL ou o log. + +**Regra de decisão:** conte travas por conta por dia durante uma semana. Conta +que trava todo dia está subdimensionada; conta que nunca trava é folga que +absorve mais gente. O resto é projeção. + +## A alavanca mais barata + +Como a trava é por família, uma cascata que **muda de família** compra capacidade +sem comprar licença. O sincronizador registra combos assim por padrão, visíveis +no painel em **Resilience combos** com a **Model cascade** +`[FONTE: src/nine_rtksync/combos.py:14-36; os dois rótulos são combos.title e +table.cascade em src/nine_rtksync/i18n.py:84-85]`: + +``` +claudegravity-fallback ag/gemini-3.8-flash-high → ag/gemini-3.7-flash-high → + ag/gemini-3.6-flash-high → ag/claude-sonnet-4-6 → + ag/gpt-oss-120b-medium +``` + +Lida contra a medição acima, a leitura honesta incomoda: os **três primeiros +degraus caem juntos** — todos deram 503 no mesmo bloqueio — então quem compra +capacidade é o quarto e o quinto, os que saem da família Gemini. Cascata de cinco +modelos dentro de uma família é um modelo com passos a mais. + +E a ilusão mais cara, medida e não argumentada: **cadastrar a mesma conta duas +vezes não dobra nada.** Duas entradas no gateway, um teto só +`[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L716-L720 — lido em 12/09/2026]`. +`L` conta **contas distintas com assinatura própria** — o mesmo número que a +licença já obrigava. + +## Renovação não é cota + +| | Validade da credencial | Cota | +| :--- | :--- | :--- | +| Duração | ~1 h — a renovação usa o `expires_in` do provedor, padrão 3599 s `[FONTE: src/nine_rtksync/providers/google.py:202]` | 5 h / semanal, publicado; 2 h por família é observação de log `[FONTE: https://github.com/pathbit/pathbit-ai-for-devs/blob/96a5a34/0002_claude_gravity_utilizando_9router/article/ARTICLE.md#L689 — lido em 12/09/2026]` | +| Sintoma | 401, desconexão "espontânea" | 429 / 503 | +| Campo | `expiresAt` | `rateLimitedUntil`, `modelLock_*` | +| Quem resolve | renovação automática — o que este serviço faz | esperar a janela, ou mais uma licença | +| Escala com o time? | **Não** | **Sim** | + +O "desconecta sozinho depois de uma hora" que manda gente comprar conta nova é +formato de gravação, não falta de cota: o 9Router grava `expiresAt` como string +ISO onde o consumidor dele espera epoch em milissegundos, e o normalizador +converte a cada varredura `[FONTE: src/nine_rtksync/normalizer.py:56-66]`. +Nenhum dos dois relógios entra na fórmula — mas só um deles se resolve com +dinheiro. + +## Onde este gateway difere + +Aqui o que se multiplexa é **conta de assinatura**, e "licença" quer dizer uma +assinatura com um titular; o teto vive dentro da conta do fornecedor, invisível, +com granularidade por conta **e por família**; a unidade de `D` é token total; e +cresce comprando conta, com titular novo. Num proxy LiteLLM a pergunta muda de +forma: lá se repartem chaves virtuais sobre uma credencial de API que já é sua e +o teto é declarado em **três** níveis, com a regra `chave ≤ time ≤ padrão da +plataforma`, conferível campo a campo — que é exatamente o assunto do irmão, +porque o LiteLLM aceita uma chave declarando mais que o time dela e depois impõe +o número menor em silêncio +`[FONTE: https://github.com/pathbit/LiteLlmRTKSync/blob/master/docs/wiki/Rate-Limit-Coherence.md — lido em 2026-09-13]`. E `C_window` nem +chega a existir daquele lado: o que a API publica é teto **por minuto** +(RPM / ITPM / OTPM por tier), não capacidade de janela +`[FONTE: https://platform.claude.com/docs/en/api/rate-limits — lido em 12/09/2026]`. +Não carregue número de uma coluna para a outra. O irmão +[OminiRTkSync](https://github.com/pathbit/OminiRTkSync) dimensiona igual a esta +coluna: mesma forma, mesmo `[A MEDIR]`, formato de expiração diferente. + +## Uma restrição que não é de capacidade + +Várias sessões numa conta não incomodam. O que chama atenção é o inverso: várias +**contas** saindo pelo mesmo endereço, que é a forma natural de um gateway com a +conta de todo mundo cadastrada. Crescer o time cai direto nisso — leia +[Egress and Multi-Session](Egress-And-Multi-Session) antes da décima conta, e +note a armadilha: pool inativo, ou sem endereço, não gera erro; a conexão cai em +silêncio para o endereço do host. + +## Reproduzindo + +```bash +python3 tools/measure_agent_usage.py # o lado da demanda, na sua máquina +python3 tools/sizing.py # as tabelas (ele imprime C_window como C_janela) +9rtksync --status # estado corrente de cada conta +``` + +Para reproduzir o bloco publicado acima em vez de medir o seu, acrescente o +recorte com que ele foi tirado — sem isso o histórico, que cresce, devolve um +número maior a cada dia: + +```bash +python3 tools/measure_agent_usage.py --until 2026-09-13T02:00:00Z +``` + +Número sem script reproduzível vira, com o tempo, número inventado. É por isso +que os dois scripts estão versionados neste repositório, ao lado desta página — +`tools/measure_agent_usage.py` e `tools/sizing.py` — e por isso que o bloco +medido acima carrega o recorte `--until` exato com que foi produzido. diff --git a/docs/wiki/Logging.md b/docs/wiki/Logging.md index 77c824e..6db3c26 100644 --- a/docs/wiki/Logging.md +++ b/docs/wiki/Logging.md @@ -33,7 +33,7 @@ covered by `tests/test_logs.py`. Another service's logs sharing the same directory are left alone. ``` -/app/data/logs/ +/app/logs/ 9rtksync.log ← active, never purged 9rtksync.log.2026-09-11 ← kept (2 days old) 9rtksync.log.2026-07-01 ← purged (73 days old, retention 30) @@ -48,7 +48,7 @@ Another service's logs sharing the same directory are left alone. environment: - LOG_RETENTION_DAYS=90 volumes: - - 9rtksync_logs:/app/data/logs + - 9rtksync_logs:/app/logs ``` Mount a named volume (or a host path) or the files die with the container, which defeats the @@ -75,7 +75,7 @@ The file log is **best effort**. If the directory cannot be created or written, starts and prints once to stderr: ``` -[LOG] File log unavailable at /app/data/logs: [Errno 13] Permission denied +[LOG] File log unavailable at /app/logs: [Errno 13] Permission denied ``` A synchronizer that refuses to run because it cannot write a log file would be worse than one diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index 4e924ea..12b72fd 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -80,7 +80,10 @@ do not control, and you accept that the address is public to whoever has it. The same screen has a `Tailscale` button, which installs and connects the daemon. Your machine joins your private tailnet and the gateway becomes reachable at a `100.x.y.z` address, or at a MagicDNS name like -`http://your-host:20128`. +`http://your-host:20128`. The port here is the gateway's **own** `20128`, +because the button starts `tailscaled` next to the gateway process — it is not +the `8081` this repository publishes on the host. Publishing on the tailnet +interface by hand, below, is the other case: there the host port applies. **When it fits:** almost always. Only devices you enrolled in your tailnet can reach the gateway — the address is not public, and there is nothing for a @@ -99,17 +102,17 @@ tailscale ip -4 # e.g. 100.101.102.103 # 3. Publish the gateway on the tailnet interface instead of loopback # (in the compose, replace 127.0.0.1 with the tailnet address) ports: - - "100.101.102.103:20128:20128" + - "100.101.102.103:8081:20128" # 4. From another device already in the tailnet -curl http://100.101.102.103:20128/v1/models +curl http://100.101.102.103:8081/v1/models ``` Binding to the tailnet address rather than `0.0.0.0` matters: `0.0.0.0` also exposes the gateway to the local network — the café Wi-Fi, the office VLAN — which is exactly what you were avoiding. -**MagicDNS** makes this readable: with it on, `http://your-host:20128` works from +**MagicDNS** makes this readable: with it on, `http://your-host:8081` works from any device in the tailnet, and the address survives a change of IP. --- @@ -154,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 9rtk-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 @@ -218,7 +335,11 @@ questão não se coloca. 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`. +`100.x.y.z`, ou num nome MagicDNS como `http://seu-host:20128`. A porta aqui é +a **própria** `20128` do gateway, porque o botão sobe o `tailscaled` ao lado do +processo do gateway — não é a `8081` que este repositório publica no host. +Publicar na interface da tailnet à mão, abaixo, é o outro caso: lá vale a porta +do host. **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. @@ -235,10 +356,10 @@ tailscale ip -4 # ex.: 100.101.102.103 # 3. Publique o gateway na interface da tailnet, em vez do loopback ports: - - "100.101.102.103:20128:20128" + - "100.101.102.103:8081:20128" # 4. De outro dispositivo já na tailnet -curl http://100.101.102.103:20128/v1/models +curl http://100.101.102.103:8081/v1/models ``` Prender no endereço da tailnet em vez de `0.0.0.0` importa: `0.0.0.0` também diff --git a/docs/wiki/Single-Sign-On.md b/docs/wiki/Single-Sign-On.md new file mode 100644 index 0000000..1f064bd --- /dev/null +++ b/docs/wiki/Single-Sign-On.md @@ -0,0 +1,353 @@ +# Single sign-on: signing in through an identity provider + +*(Versão em português ao final.)* + +The panel has always had two doors, and single sign-on adds a third way of +*creating a session* — not a third kind of session. The cookie handed out at the +end of the federated flow is byte for byte the one the local form hands out +today, signed by the same per-process secret in +[`src/nine_rtksync/sessao.py`](https://github.com/pathbit/9RTKSync/blob/master/src/nine_rtksync/sessao.py). + +> **It is optional, and it starts off.** With nothing configured the panel +> behaves exactly as it does today: the sign-in screen is the same, and the +> `/sso/` routes answer 404 like any route that does not exist. + +--- + +## What is implemented, and what is not + +| | Status | +|---|---| +| OIDC sign-in (discovery, PKCE, code exchange, userinfo, allowlist) | **Implemented**, no external dependency | +| SAML2 configuration tab | **Shown, disabled**, with the reason on screen | +| SAML2 sign-in (assertion validation) | **Not implemented** — needs `python3-saml`, see the last section | +| Federated sign-out (single logout) | **Out of scope** | + +The honest reason for the split: SAML2 signs over exclusive canonicalisation +1.0, and the standard library's `canonicalize` implements a different algorithm; +there is no RSA verification in the standard library; and defending against a +signed assertion moved elsewhere in the document tree requires the code that +checks the signature to also check that the validated element is the one that +was read. That is library work, and the library is not in the image. Shipping a +hand-rolled version of it would be worse than shipping nothing, because a +wrongly canonicalised signature check is one relaxation away from accepting a +forged assertion. + +--- + +## The rule, end to end + +1. The sign-in screen keeps the local user-and-password form. It never + disappears — if the identity provider is down, nobody would get in. +2. When single sign-on is configured **and** the provider's discovery document + answers, the screen also draws a **link** (never a form: the panel's own + content policy declares `form-action 'self'`, and a form pointing outside is + blocked by the browser with no visible error). +3. Clicking it sends the browser to the provider with PKCE, `state` and `nonce`. + The three values are signed into a short-lived cookie that lives for ten + minutes, is scoped to the `/sso/` path, and is consumed on the way back. +4. On the return, before any session is created, the panel checks, stopping at + the first failure: the state cookie's signature; `state`; the presence of the + authorization code; the code exchange at the provider; then `iss`, `aud`, + `azp`, `exp`, `iat` and `nonce` of the identity token; then the subject read + from the user-information endpoint against the one in the token; then + `email_verified`; then the allowlist. +5. Only then is the session cookie issued, for the principal `sso:`. +6. Every failure shows **one** message. Telling apart "wrong state" from "e-mail + not allowed" tells an attacker how far they got; the detail goes to the + internal log, which never carries the code or any token. + +--- + +## Configuring it against Google (a worked example) + +**Before anything**: single sign-on needs a **fixed public address**. A quick +tunnel gets a new address on every start, and the return address you registered +at the provider stops matching the moment it changes — every sign-in then fails +with a provider-side error you cannot fix from here. Use a named tunnel or +Tailscale. See [Remote Access](Remote-Access). + +1. **In the Google Cloud console**, create an OAuth client of type *Web + application*. +2. Add the **authorised redirect URI**. The panel shows the exact string to copy + under the Settings button once you fill in the public address; it is your + public address followed by `/sso/oidc/callback`. One character of difference + and Google refuses the exchange. +3. Copy the client ID and the client secret. +4. **In the panel**, sign in locally and open **Settings** in the header bar. +5. Fill in, on the OIDC tab: + - *Identity provider*: OIDC + - *Public panel address*: your fixed address, scheme and host only + - *Issuer*: `https://accounts.google.com` + - *Client ID* and *Client secret* + - *Allowed domains*: your company domain — **this is mandatory**. Without it, + "sign in with Google" means every Google account on the planet can sign in + to your panel. +6. Type your **current panel password** and save. The password is required on + top of the session on purpose: whoever steals an eight-hour session cookie + must not be able to point the panel at an identity provider of their own and + put themselves on the allowlist. +7. Sign out and check that the sign-in screen now shows both the local form and + the provider button. + +Any provider that publishes a discovery document works the same way — Microsoft +Entra, Okta, Authentik, Keycloak. Two notes from the checks above: the issuer +must match the `issuer` field of the discovery document character for character, +and providers that omit `email_verified` will not pass, because a panel that +reads live credentials should not trust an unconfirmed e-mail address. + +### Where the secret lives + +The client secret **never** goes into the preferences database, and a page load +never sends it back to the browser — the screen says only that a value is +stored, and offers a field to replace it. Saving with that field empty keeps the +current value. + +Two places, in order of precedence: + +1. `OIDC_CLIENT_SECRET` in the environment. When it is set, the environment is + the source of truth and the screen locks the field — the same rule + `DASHBOARD_PASSWORD` already follows. +2. A file named `.sso_client_secret`, created with mode `0600` next to the + recovery credential, inside the data directory (`DATA_DIR`, or the directory + of the gateway database). + +If neither exists, single sign-on does not turn on. It does not turn on quietly +without a secret, which would put a button on the sign-in screen leading to a +generic failure. + +--- + +## When the provider goes down + +Nothing here traps you outside the panel. In increasing order of bluntness: + +1. **The local form never leaves the screen.** Your panel user and password + still work, always. +2. **The recovery credential still works.** See [Authentication](Authentication). +3. **The non-HTML door is untouched.** `curl`, cron and monitoring keep signing + in with Basic Auth, which never goes through single sign-on. +4. **The button disappears on its own.** The sign-in screen asks the provider + for its discovery document; when that fails the button is simply not drawn, + and the failure is remembered for a minute so a dead provider cannot make + every sign-in screen wait for a timeout. +5. **The emergency switch**: set `SSO_DISABLED=1` in the environment and bring + the container back up. It beats the database without touching it, so nothing + you configured is lost when you take the variable back out. +6. **From the screen**: open Settings, set the provider to *Disabled*, type the + panel password, save. + +One thing that looks like a bug and is not: signing out clears the **local** +cookie only. Your session at the identity provider is still open, so clicking +the provider button again comes back in without asking for a password. Federated +sign-out is not implemented. + +--- + +## The routes + +| Route | Who calls it | Session required | +|---|---|---| +| `/sso/oidc/iniciar` | the browser, from the sign-in screen | no — it is the session it exists to create | +| `/sso/oidc/callback` | the browser, coming back from the provider | no — same reason | +| `/acoes/sso` | the settings screen | yes, **plus the current panel password** | + +The two public ones go through the same per-address attempt ceiling as the +sign-in form, answer 404 while single sign-on is not configured and enabled, and +are declared with their reason in `tests/test_nada_sem_login.py`, which is the +guard that refuses any new route escaping the session requirement. + +Two details that are decisions, not accidents: + +- The return address sent to the provider is always built from the configured + public address, **never** from the request's `Host` header. `Host` is chosen + by whoever is calling, and deriving the return address from it is the + definition of an open redirect. +- The callback answers **200 with a small page that refreshes to `/`**, not a + 302. A redirect chain that started on another site does not carry a + `SameSite=Strict` cookie on the next hop, so a 302 would land the operator on + the sign-in screen holding a perfectly valid session. No parameter of the + return is ever used as the destination; it is always `/`. + +--- + +## What the SAML2 phase needs + +The configuration tab is already drawn and translated, and it stays disabled +until the library is in the image. Adding it means changing the Dockerfile of +all three panels — which is why it is a separate phase: + +``` +RUN apk add --no-cache libxml2 libxslt xmlsec && \ + apk add --no-cache --virtual .build gcc musl-dev libxml2-dev libxslt-dev xmlsec-dev pkgconfig && \ + pip install --no-cache-dir --no-binary lxml,xmlsec python3-saml && \ + apk del .build +``` + +Building the two extensions from source is not optional: the published wheels of +`lxml` and `xmlsec` embed different versions of the same XML library, and the +mismatch surfaces at runtime, not at install time. The extra is already declared +as `saml` in `pyproject.toml`, so `pip install .[saml]` is enough outside Docker. + +--- + +# Em português + +O painel sempre teve duas portas, e a entrada federada acrescenta uma terceira +forma de **criar uma sessão** — não uma terceira espécie de sessão. O cookie +entregue no fim do fluxo é exatamente o que o formulário local entrega hoje, +assinado pelo mesmo segredo de processo de `sessao.py`. + +> **É opcional, e nasce desligada.** Sem configuração o painel funciona +> exatamente como hoje: a tela de entrada é a mesma, e as rotas `/sso/` +> respondem 404 como qualquer rota que não existe. + +## O que está pronto, e o que não está + +| | Situação | +|---|---| +| Entrada por OIDC (descoberta, PKCE, troca do código, perfil, lista de permissão) | **Pronta**, sem dependência externa | +| Aba de configuração SAML2 | **Aparece, desabilitada**, com o motivo na tela | +| Entrada por SAML2 (validação da asserção) | **Não implementada** — exige `python3-saml` | +| Saída federada (logout no provedor) | **Fora de escopo** | + +O motivo honesto da divisão: a assinatura do SAML2 é sobre canonicalização +exclusiva 1.0, e o `canonicalize` da biblioteca padrão implementa outro +algoritmo; não há verificação RSA na biblioteca padrão; e defender-se de uma +asserção assinada deslocada dentro da árvore exige que quem confere a assinatura +confira também que o elemento validado é o mesmo que foi lido. Isso é trabalho +de biblioteca, e a biblioteca não está na imagem. Entregar uma versão artesanal +disso seria pior que não entregar nada: uma canonicalização errada está a uma +"correção" de distância de aceitar uma asserção forjada. + +## A regra, de ponta a ponta + +1. A tela de entrada mantém o formulário local de usuário e senha. Ele nunca + sai — se o provedor de identidade cair, ninguém entraria. +2. Quando há configuração completa **e** o documento de descoberta responde, a + tela também desenha um **link** (nunca um formulário: a política de conteúdo + do painel declara `form-action 'self'`, e um formulário apontando para fora é + bloqueado pelo navegador sem erro visível). +3. O clique manda o navegador ao provedor com PKCE, `state` e `nonce`. Os três + viajam assinados num cookie de dez minutos, preso ao caminho `/sso/` e + consumido na volta. +4. Na volta, antes de qualquer sessão, o painel confere, parando na primeira + falha: a assinatura do cookie de estado; o `state`; a presença do código; a + troca do código no provedor; `iss`, `aud`, `azp`, `exp`, `iat` e `nonce` do + token de identidade; o identificador de conta lido do perfil contra o do + token; o `email_verified`; e a lista de permissão. +5. Só então o cookie de sessão é emitido, para `sso:`. +6. Toda falha exibe **uma** frase. Distinguir "state errado" de "e-mail fora da + lista" conta ao atacante até onde ele chegou; o detalhe vai para o log + interno, que nunca carrega o código nem token nenhum. + +## Configurando no Google (exemplo completo) + +**Antes de tudo**: a entrada federada exige um **endereço público fixo**. O túnel +rápido troca de endereço a cada subida, e o endereço de retorno cadastrado no +provedor deixa de bater no instante em que isso acontece. Use túnel nomeado ou +Tailscale — veja [Remote Access](Remote-Access). + +1. **No console do Google Cloud**, crie um cliente OAuth do tipo *aplicação web*. +2. Cadastre o **endereço de retorno autorizado**. O painel mostra a linha exata + para copiar, no botão Configurações, assim que você preenche o endereço + público; é o seu endereço seguido de `/sso/oidc/callback`. Um caractere de + diferença e o Google recusa a troca. +3. Copie o identificador e o segredo do cliente. +4. **No painel**, entre com a senha local e abra **Configurações**, na barra do + cabeçalho. +5. Preencha, na aba OIDC: + - *Provedor de identidade*: OIDC + - *Endereço público do painel*: o seu endereço fixo, só esquema e host + - *Emissor*: `https://accounts.google.com` + - *Identificador* e *segredo do cliente* + - *Domínios autorizados*: o domínio da sua empresa — **isto é obrigatório**. + Sem ele, "entrar com o Google" significa que toda conta Google do planeta + entra no seu painel. +6. Digite a **senha atual do painel** e salve. Ela é exigida além da sessão de + propósito: quem rouba um cookie de oito horas não pode com isso apontar o + painel a um provedor próprio e se pôr na lista de permissão. +7. Saia e confira que a tela de entrada agora mostra o formulário local **e** o + botão do provedor. + +Qualquer provedor que publique documento de descoberta funciona igual — Entra, +Okta, Authentik, Keycloak. Dois avisos vindos das conferências acima: o emissor +tem de bater caractere a caractere com o campo `issuer` do documento, e provedor +que não envia `email_verified` não passa, porque um painel que lê credenciais não +deve confiar num e-mail não confirmado. + +### Onde mora o segredo + +O segredo do cliente **nunca** vai para o banco de preferências, e nenhuma +renderização o devolve ao navegador — a tela diz apenas que existe um valor +guardado, e oferece um campo para substituí-lo. Salvar com esse campo em branco +mantém o valor atual. + +Dois lugares, nesta ordem de precedência: + +1. `OIDC_CLIENT_SECRET` no ambiente. Definida, o ambiente é a fonte da verdade e + a tela trava o campo — a mesma regra que `DASHBOARD_PASSWORD` já segue. +2. Um arquivo `.sso_client_secret`, criado com modo `0600` ao lado da credencial + de recuperação, dentro do diretório de dados (`DATA_DIR`, ou o diretório do + banco do gateway). + +Sem nenhum dos dois, o SSO não liga. Ele não liga em silêncio sem segredo, o que +poria um botão na tela levando a uma falha genérica. + +## Quando o provedor cai + +Nada aqui tranca você do lado de fora. Em ordem crescente de contundência: + +1. **O formulário local nunca sai da tela.** O seu usuário e senha continuam + valendo, sempre. +2. **A credencial de recuperação continua valendo.** Veja [Authentication](Authentication). +3. **A porta não-HTML está intocada.** `curl`, cron e monitoramento continuam + entrando por Basic Auth, que nunca passa pelo SSO. +4. **O botão some sozinho.** A tela pergunta ao provedor pelo documento de + descoberta; quando isso falha o botão simplesmente não é desenhado, e a falha + fica memorizada por um minuto para que um provedor morto não faça cada tela de + entrada esperar o tempo limite. +5. **O interruptor de emergência**: `SSO_DISABLED=1` no ambiente e suba o + container de novo. Ele vence o banco sem tocar nele, então nada do que você + configurou se perde quando a variável sair. +6. **Pela tela**: abra Configurações, ponha o provedor em *Desligado*, digite a + senha do painel e salve. + +Uma coisa que parece defeito e não é: "Sair" apaga só o cookie **local**. A sua +sessão no provedor continua de pé, então clicar de novo no botão volta sem pedir +senha. A saída federada não está implementada. + +## As rotas + +| Rota | Quem chama | Exige sessão | +|---|---|---| +| `/sso/oidc/iniciar` | o navegador, pela tela de entrada | não — é a sessão que ela existe para criar | +| `/sso/oidc/callback` | o navegador, voltando do provedor | não — mesmo motivo | +| `/acoes/sso` | a tela de configuração | sim, **e ainda a senha atual do painel** | + +As duas públicas passam pelo mesmo teto de tentativas por endereço do formulário +de entrada, respondem 404 enquanto o SSO não estiver configurado e ligado, e +estão declaradas com o motivo em `tests/test_nada_sem_login.py`, que é a guarda +que recusa qualquer rota nova escapando da exigência de sessão. + +Dois detalhes que são decisão, e não acaso: + +- O endereço de retorno enviado ao provedor nasce sempre do endereço público + configurado, **nunca** do cabeçalho `Host` da requisição. O `Host` é escolhido + por quem chama, e derivar dali o endereço de retorno é a definição de + redirecionamento aberto. +- A volta responde **200 com uma página que recarrega para `/`**, e não um 302. + Uma cadeia de redirecionamento iniciada em outro site não carrega o cookie + `SameSite=Strict` no salto seguinte, então o 302 deixaria o operador na tela de + entrada com uma sessão perfeitamente válida no bolso. Nenhum parâmetro da volta + vira destino; ele é sempre `/`. + +## O que a fase do SAML2 exige + +A aba de configuração já está desenhada e traduzida, e fica desabilitada até a +biblioteca entrar na imagem. Pô-la lá significa mudar o Dockerfile dos três +painéis — e é por isso que é uma fase separada. O bloco está na versão em inglês, +logo acima. Compilar as duas extensões a partir do fonte não é opcional: os +pacotes prontos de `lxml` e de `xmlsec` embutem versões diferentes da mesma +biblioteca de XML, e o desencontro aparece em tempo de execução, não na +instalação. O extra já está declarado como `saml` no `pyproject.toml`. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 04423dc..0d146b4 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, under **Renewal diagnosis** in the details modal — +the button at the end of the connection row: > 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 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/nine_rtksync/web/server.py", line 116, in serve_healthz +File "/app/src/nine_rtksync/web.py", line 116, in serve_healthz self.wfile.write(b"OK") BrokenPipeError: [Errno 32] Broken pipe ``` @@ -89,15 +90,15 @@ 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 9rtksync 2>&1 | grep "Recovery hash" +docker logs 9rtk-sync 2>&1 | grep "Recovery hash" # or, if the log file is mounted: -grep "Recovery hash" /app/data/logs/9rtksync.log +grep "Recovery hash" /app/logs/9rtksync.log ``` If the log has already rotated past it, the value is on disk: ```bash -docker exec 9rtksync cat /app/data/.dashboard_recovery +docker exec 9rtk-sync cat /app/data/.dashboard_recovery ``` To pin your own instead of relying on the generated one, set `DASHBOARD_RECOVERY_HASH` and @@ -119,7 +120,7 @@ The file log is best-effort — the synchronizer never refuses to start because you will see: ``` -[LOG] File log unavailable at /app/data/logs: [Errno 13] Permission denied +[LOG] File log unavailable at /app/logs: [Errno 13] Permission denied ``` Fix the volume permissions, or point `LOG_DIR` somewhere writable. Events keep going to stdout diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index cfca41a..93ae6fe 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 063a27e..599d605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,15 @@ classifiers = [ "Topic :: Utilities", ] +[project.optional-dependencies] +# SAML2 nao sai com a biblioteca padrao: a assinatura e sobre canonicalizacao +# exclusiva 1.0 (o `canonicalize` do xml.etree e C14N 2.0, outro algoritmo), nao +# ha verificacao RSA na stdlib, e amarrar a referencia da assinatura ao elemento +# efetivamente lido -- a defesa contra o deslocamento da assercao na arvore -- e +# trabalho de biblioteca. Extra opcional: quem so usa OIDC nao carrega nada, e o +# codigo importa a biblioteca dentro da funcao. +saml = ["python3-saml>=1.16"] + [project.urls] Homepage = "https://github.com/pathbit/9RTKSync" Repository = "https://github.com/pathbit/9RTKSync" @@ -33,3 +42,10 @@ Issues = "https://github.com/pathbit/9RTKSync/issues" [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/nine_rtksync/__init__.py b/src/nine_rtksync/__init__.py index 4e1b31c..c711291 100644 --- a/src/nine_rtksync/__init__.py +++ b/src/nine_rtksync/__init__.py @@ -1,7 +1,7 @@ -"""9rtksync: 9Router Token & Connection Sync. +"""Sincronizador universal de tokens e conexoes de um gateway de IA. Universal credential keeper, auto-healer and connection synchronization -service for 9Router AI Gateways. +service for the AI gateway it is paired with. """ __version__ = "1.0.0" diff --git a/src/nine_rtksync/cli.py b/src/nine_rtksync/cli.py index e3c98a5..6755528 100644 --- a/src/nine_rtksync/cli.py +++ b/src/nine_rtksync/cli.py @@ -1,13 +1,14 @@ -"""Command line interface (CLI) for 9RTKSync.""" +"""Command line interface (CLI) for this synchronizer.""" import argparse import sys from .config import Settings from .daemon import SyncEngine, run_daemon -from .database import get_all_combos, get_all_connections +from .identidade import NOME_DO_GATEWAY, NOME_DO_PRODUTO +from .gateway import get_all_combos, get_all_connections from .logs import setup_logging -from .web.server import start_web_server +from .web import start_web_server def print_status_table(settings: Settings): @@ -59,13 +60,13 @@ def print_status_table(settings: Settings): def main(): parser = argparse.ArgumentParser( - prog="9RTKSync", - description="9RTKSync · 9Router Universal Token & Connection Synchronizer", + prog=NOME_DO_PRODUTO, + description=f"{NOME_DO_PRODUTO} · {NOME_DO_GATEWAY} Universal Token & Connection Synchronizer", ) parser.add_argument( "--db-path", dest="db_path", - help="Path to 9Router SQLite database (data.sqlite)", + help=f"Path to {NOME_DO_GATEWAY} SQLite database (data.sqlite)", ) parser.add_argument( "--status", diff --git a/src/nine_rtksync/combos.py b/src/nine_rtksync/combos.py index b086137..d26088b 100644 --- a/src/nine_rtksync/combos.py +++ b/src/nine_rtksync/combos.py @@ -1,9 +1,9 @@ -"""Manager for resilience and fallback combos in 9Router SQLite database.""" +"""Manager for resilience and fallback combos in the gateway SQLite database.""" import json from typing import List, Tuple -from .database import upsert_combos +from .gateway import upsert_combos def get_default_combos(module: str = "all") -> List[Tuple[str, str, str, str]]: diff --git a/src/nine_rtksync/config.py b/src/nine_rtksync/config.py index 3445e9c..fc5dbaf 100644 --- a/src/nine_rtksync/config.py +++ b/src/nine_rtksync/config.py @@ -1,4 +1,4 @@ -"""Global settings and environment variable loading for 9RTKSync.""" +"""Global settings and environment variable loading for this synchronizer.""" import os import sys @@ -44,7 +44,7 @@ def load_dotenv(dotenv_path: str = ".env") -> None: @dataclass class Settings: - """Runtime configuration for 9RTKSync synchronizer.""" + """Runtime configuration for this synchronizer.""" db_path: str sync_interval: int = 300 refresh_margin: int = 900 @@ -222,7 +222,7 @@ def from_env(cls, env_file: str = ".env") -> "Settings": ] valid_paths = [p for p in default_paths if p] - # SQLite database discovery for 9Router and OmniRoute + # SQLite database discovery for the gateway db_path = os.environ.get("DB_PATH", "") if not db_path: candidate_dbs = [ diff --git a/src/nine_rtksync/credential_check.py b/src/nine_rtksync/credential_check.py index b92b02b..debb9a6 100644 --- a/src/nine_rtksync/credential_check.py +++ b/src/nine_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 = "9RTKSync-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/nine_rtksync/cron.py b/src/nine_rtksync/cron.py index 332a139..b3d02fb 100644 --- a/src/nine_rtksync/cron.py +++ b/src/nine_rtksync/cron.py @@ -1,21 +1,22 @@ -"""Background scheduling engine (CronScheduler) for 9RTKSync.""" +"""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 def _extract_log_lines(res: Any) -> List[str]: - """Extract the actions the sync engine recorded during this cycle. + """Extrai as acoes registradas pelo motor de sincronizacao neste ciclo. - Keeps only what explains the outcome — error, renewal, self-healing. A cycle - with nothing to do returns an empty list, and the screen shows it as such. + Guarda so o que explica o resultado — erro, renovacao, auto-cura. Um ciclo + sem nada a fazer devolve lista vazia, e a tela mostra isso como tal. """ if not isinstance(res, dict): - return [f"Unexpected engine result: {res!r}"] + return [f"Resultado inesperado do motor: {res!r}"] lines: List[str] = [] if res.get("error"): @@ -33,13 +34,13 @@ def _extract_log_lines(res: Any) -> List[str]: class CronScheduler: - """Background scheduler managing continuous OAuth account renewals and connection health.""" + """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 = "9RTKSync-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() - # Execution metrics + # Metricas de execucao self.total_runs = 0 self.total_renewals = 0 self.last_run_at: Optional[str] = None @@ -58,7 +59,7 @@ def __init__( self.history: List[Dict[str, Any]] = [] def start(self): - """Start the background cron worker thread.""" + """Sobe a thread de cron em background.""" with self._lock: if self.is_running: return @@ -69,17 +70,17 @@ def start(self): self._thread.start() def stop(self): - """Gracefully stop the background cron thread.""" + """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]: - """Trigger an immediate synchronous run of the sync cycle.""" + """Dispara um ciclo de sincronizacao agora, de forma sincrona.""" return self._execute_cycle(reason="manual_trigger") def get_status(self) -> Dict[str, Any]: - """Return a detailed snapshot of the scheduler state for the API and dashboard.""" + """Retrato detalhado do agendador para a API e para o painel.""" with self._lock: return { "active": self.is_running, @@ -101,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] Cycle triggered ({reason}). Inspecting OAuth account connections...") + get_logger().info(f"[CRON] Ciclo disparado ({reason}). Inspecionando conexoes de contas OAuth...") try: res = self.sync_callback() @@ -134,19 +135,16 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: self._update_next_run(self.interval_seconds) get_logger().info( - f"[CRON] Cycle completed in {duration_ms}ms: {total} accounts evaluated, " - f"{refreshed} renewed via OAuth." + f"[CRON] Ciclo concluido em {duration_ms}ms: {total} contas avaliadas, " + f"{refreshed} renovadas via OAuth." ) return entry def _run_loop(self): - # Execute startup sync cycle self._execute_cycle(reason="startup") - while not self._stop_event.is_set(): interrupted = self._stop_event.wait(timeout=self.interval_seconds) if interrupted: break if self.is_running: self._execute_cycle(reason="scheduled_interval") - diff --git a/src/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index 80d774a..46454a8 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -1,4 +1,4 @@ -"""Continuous synchronization and universal self-healing daemon for 9Router.""" +"""Continuous synchronization and universal self-healing daemon for the gateway.""" import os import signal @@ -11,13 +11,14 @@ from .combos import sync_combos from .config import Settings from .cron import CronScheduler -from .database import get_all_connections, update_connection_data +from .identidade import NOME_DO_PRODUTO +from .gateway import get_all_connections, update_connection_data from .discovery import HostDiscoveryEngine from .logs import get_logger from .models import ConnectionRecord from .normalizer import normalize_connection_data from .providers import ApiKeyProvider, BaseProvider, GenericOAuthProvider, GoogleProvider, LocalProvider -from .web.server import start_web_server +from .web import start_web_server # Prefixes that describe a failure. Emitting everything at INFO meant that @@ -82,8 +83,16 @@ def sync_all(self) -> Dict[str, Any]: def _sync_all_locked(self) -> Dict[str, Any]: if not os.path.exists(self.settings.db_path): - log_msg("ERROR", f"SQLite database not found at: {self.settings.db_path}") - return {"success": False, "error": "db_not_found"} + # 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()} summary = { "timestamp": datetime.now(timezone.utc).isoformat(), @@ -193,7 +202,7 @@ def run_daemon(settings: Settings): def handle_signal(sig, frame): nonlocal running - print(f"\n[!] Signal {sig} received. Shutting down 9RTKSync gracefully...", flush=True) + print(f"\n[!] Signal {sig} received. Shutting down {NOME_DO_PRODUTO} gracefully...", flush=True) running = False signal.signal(signal.SIGINT, handle_signal) @@ -221,7 +230,7 @@ def handle_signal(sig, frame): cron_scheduler = CronScheduler( sync_callback=engine.sync_all, interval_seconds=settings.cron_interval, - name="9RTKSync-CronScheduler", + name=f"{NOME_DO_PRODUTO}-CronScheduler", ) # Start embedded web server if enabled @@ -250,5 +259,5 @@ def handle_signal(sig, frame): time.sleep(1) cron_scheduler.stop() - print("[*] 9RTKSync terminated cleanly.", flush=True) + print(f"[*] {NOME_DO_PRODUTO} terminated cleanly.", flush=True) diff --git a/src/nine_rtksync/database.py b/src/nine_rtksync/database.py deleted file mode 100644 index c34d199..0000000 --- a/src/nine_rtksync/database.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Safe access and mutation for 9Router SQLite database.""" - -import json -import os -import sqlite3 -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional - -from .models import ConnectionRecord - - -def get_db_connection(db_path: str) -> sqlite3.Connection: - """Open connection to SQLite with timeout and Row factory.""" - if not os.path.exists(db_path): - raise FileNotFoundError(f"SQLite database not found at: {db_path}") - conn = sqlite3.connect(db_path, timeout=15.0) - conn.row_factory = sqlite3.Row - return conn - - -def get_all_connections(db_path: str) -> List[ConnectionRecord]: - """Retrieve all registered connections from 9Router.""" - conn = get_db_connection(db_path) - try: - cursor = conn.cursor() - cursor.execute("SELECT id, provider, name, createdAt, updatedAt, data FROM providerConnections") - rows = cursor.fetchall() - result = [] - for r in rows: - result.append( - ConnectionRecord( - id=r["id"], - provider=r["provider"], - name=r["name"], - created_at=r["createdAt"], - updated_at=r["updatedAt"], - data_raw=r["data"], - ) - ) - return result - finally: - conn.close() - - -def update_connection_data(db_path: str, connection_id: str, new_data: Dict[str, Any]) -> bool: - """Update connection JSON payload and timestamp updatedAt with UTC ISO string.""" - conn = get_db_connection(db_path) - now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - try: - cursor = conn.cursor() - data_str = json.dumps(new_data) - cursor.execute( - "UPDATE providerConnections SET data = ?, updatedAt = ? WHERE id = ?", - (data_str, now_iso, connection_id), - ) - conn.commit() - return cursor.rowcount > 0 - finally: - conn.close() - - -def upsert_connection( - db_path: str, - provider: str, - name: str, - data: Dict[str, Any], - connection_id: Optional[str] = None, -) -> str: - """Insert or update a connection in SQLite ensuring compatible schema format.""" - conn = get_db_connection(db_path) - now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - try: - cursor = conn.cursor() - data_str = json.dumps(data) - - # Look up by ID or by provider - target_id = connection_id - if not target_id: - cursor.execute("SELECT id FROM providerConnections WHERE provider = ?", (provider,)) - row = cursor.fetchone() - if row: - target_id = row["id"] - - if target_id: - cursor.execute( - "UPDATE providerConnections SET name = ?, data = ?, updatedAt = ? WHERE id = ?", - (name, data_str, now_iso, target_id), - ) - conn.commit() - return target_id - else: - import uuid - new_id = str(uuid.uuid4()) - cursor.execute( - """ - INSERT INTO providerConnections (id, provider, name, data, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) - """, - (new_id, provider, name, data_str, now_iso, now_iso), - ) - conn.commit() - return new_id - finally: - conn.close() - - -def upsert_combos(db_path: str, combos_list: List[tuple]) -> int: - """ - Register or update combos in 9Router without violating unique constraint on combos.name. - combos_list: list of tuples (id, name, kind, models_json) - """ - conn = get_db_connection(db_path) - now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - count = 0 - try: - cursor = conn.cursor() - for c_id, c_name, c_kind, c_models in combos_list: - # Query by name or id - cursor.execute("SELECT id FROM combos WHERE name = ? OR id = ?", (c_name, c_id)) - row = cursor.fetchone() - if row: - existing_id = row[0] - cursor.execute( - "UPDATE combos SET models = ?, kind = ?, updatedAt = ? WHERE id = ?", - (c_models, c_kind, now_iso, existing_id), - ) - else: - cursor.execute( - """ - INSERT INTO combos (id, name, kind, models, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) - """, - (c_id, c_name, c_kind, c_models, now_iso, now_iso), - ) - count += 1 - conn.commit() - return count - finally: - conn.close() - - -def get_all_combos(db_path: str) -> List[Dict[str, Any]]: - """Return all registered combos from database.""" - conn = get_db_connection(db_path) - try: - cursor = conn.cursor() - cursor.execute("SELECT id, name, kind, models, updatedAt FROM combos ORDER BY name ASC") - rows = cursor.fetchall() - result = [] - for r in rows: - try: - models = json.loads(r["models"]) - except Exception: - models = [] - result.append({ - "id": r["id"], - "name": r["name"], - "kind": r["kind"], - "models": models, - "updatedAt": r["updatedAt"], - }) - return result - finally: - conn.close() diff --git a/src/nine_rtksync/discovery.py b/src/nine_rtksync/discovery.py index 5a5a5db..dbc0b68 100644 --- a/src/nine_rtksync/discovery.py +++ b/src/nine_rtksync/discovery.py @@ -242,7 +242,7 @@ def discover_all(self) -> Dict[str, Any]: } def get_credential_for_provider(self, provider: str) -> Optional[Dict[str, Any]]: - """Find matching credentials for a 9Router or OmniRoute provider.""" + """Find matching credentials for a gateway provider.""" p_lower = provider.lower() if p_lower in ("antigravity", "gemini-cli", "google"): return self.discover_google() diff --git a/src/nine_rtksync/gateway.py b/src/nine_rtksync/gateway.py new file mode 100644 index 0000000..fee549a --- /dev/null +++ b/src/nine_rtksync/gateway.py @@ -0,0 +1,533 @@ +"""Tudo o que sabe o que ESTE gateway guarda, e onde. + +Junto de `identidade.py`, é o único módulo do pacote que pode divergir dos +irmãos: os três painéis desenham as mesmas telas, mas um lê SQLite, outro lê +SQLite com outro schema e o terceiro fala HTTP com um Postgres atrás. Essa +diferença é real e não se apaga -- o que se faz é confiná-la aqui, atrás de uma +costura de assinatura fixa, para que `web.py` e `render.py` possam ser o mesmo +texto nos três. + +A costura é `carregar_painel(settings)`. Ela devolve SEMPRE as mesmas oito +chaves, na mesma ordem, e nunca omite nenhuma: o gateway que não tem uma delas +devolve lista ou dicionário vazio, e a tela desenha o cartão em estado vazio -- +comportamento que os três painéis já têm. Omitir a chave, em vez de esvaziá-la, +transformaria uma ausência de dado num `KeyError` no meio do render. + +Este gateway guarda conexões, chaves virtuais e combos em SQLite, e NÃO guarda +o catálogo de modelos em tabela nenhuma: ele monta `/v1/models` em tempo de +requisição. Por isso a leitura do catálogo aqui é HTTP, e não SQL. +""" + +import json +import os +import sqlite3 +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from .identidade import NOME_DO_PRODUTO +from .models import ConnectionRecord, RegisteredModelRecord, VirtualKeyRecord + +# Timeout da sondagem ao gateway. Curto de proposito: a pagina e montada no +# servidor, e o operador espera por ela. Constante nomeada porque um timeout +# esquecido aqui pendura a thread que serve a requisicao. +TIMEOUT_DE_SONDAGEM = 3.0 + +# Tempo de vida do catalogo memorizado. Ele sai de uma requisicao HTTP a este +# gateway e muda de hora em hora, nao de segundo em segundo: relê-lo a cada +# desenho da pagina cobraria uma viagem de rede por F5. O cache fica AQUI, e nao +# em web.py, porque o custo que ele evita e deste gateway -- o irmao que guarda +# o catalogo no proprio banco nao paga viagem nenhuma e nao precisa de cache. +TTL_DO_CATALOGO = 120.0 + +_catalogo_memorizado: Dict[str, tuple] = {} +_trava_do_catalogo = threading.Lock() + + +def get_db_connection(db_path: str) -> sqlite3.Connection: + """Open connection to SQLite with timeout and Row factory.""" + if not os.path.exists(db_path): + raise FileNotFoundError(f"SQLite database not found at: {db_path}") + conn = sqlite3.connect(db_path, timeout=15.0) + conn.row_factory = sqlite3.Row + return conn + + +def get_all_connections(db_path: str) -> List[ConnectionRecord]: + """Retrieve all registered connections from the gateway.""" + conn = get_db_connection(db_path) + try: + cursor = conn.cursor() + cursor.execute("SELECT id, provider, name, createdAt, updatedAt, data FROM providerConnections") + rows = cursor.fetchall() + result = [] + for r in rows: + result.append( + ConnectionRecord( + id=r["id"], + provider=r["provider"], + name=r["name"], + created_at=r["createdAt"], + updated_at=r["updatedAt"], + data_raw=r["data"], + ) + ) + return result + finally: + conn.close() + + +def update_connection_data(db_path: str, connection_id: str, new_data: Dict[str, Any]) -> bool: + """Update connection JSON payload and timestamp updatedAt with UTC ISO string.""" + conn = get_db_connection(db_path) + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + try: + cursor = conn.cursor() + data_str = json.dumps(new_data) + cursor.execute( + "UPDATE providerConnections SET data = ?, updatedAt = ? WHERE id = ?", + (data_str, now_iso, connection_id), + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def upsert_connection( + db_path: str, + provider: str, + name: str, + data: Dict[str, Any], + connection_id: Optional[str] = None, +) -> str: + """Insert or update a connection in SQLite ensuring compatible schema format.""" + conn = get_db_connection(db_path) + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + try: + cursor = conn.cursor() + data_str = json.dumps(data) + + # Look up by ID or by provider + target_id = connection_id + if not target_id: + cursor.execute("SELECT id FROM providerConnections WHERE provider = ?", (provider,)) + row = cursor.fetchone() + if row: + target_id = row["id"] + + if target_id: + cursor.execute( + "UPDATE providerConnections SET name = ?, data = ?, updatedAt = ? WHERE id = ?", + (name, data_str, now_iso, target_id), + ) + conn.commit() + return target_id + else: + import uuid + new_id = str(uuid.uuid4()) + cursor.execute( + """ + INSERT INTO providerConnections (id, provider, name, data, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + """, + (new_id, provider, name, data_str, now_iso, now_iso), + ) + conn.commit() + return new_id + finally: + conn.close() + + +def upsert_combos(db_path: str, combos_list: List[tuple]) -> int: + """ + Register or update combos without violating unique constraint on combos.name. + combos_list: list of tuples (id, name, kind, models_json) + """ + conn = get_db_connection(db_path) + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + count = 0 + try: + cursor = conn.cursor() + for c_id, c_name, c_kind, c_models in combos_list: + # Query by name or id + cursor.execute("SELECT id FROM combos WHERE name = ? OR id = ?", (c_name, c_id)) + row = cursor.fetchone() + if row: + existing_id = row[0] + cursor.execute( + "UPDATE combos SET models = ?, kind = ?, updatedAt = ? WHERE id = ?", + (c_models, c_kind, now_iso, existing_id), + ) + else: + cursor.execute( + """ + INSERT INTO combos (id, name, kind, models, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + """, + (c_id, c_name, c_kind, c_models, now_iso, now_iso), + ) + count += 1 + conn.commit() + return count + finally: + conn.close() + + +def _tabela_existe(conn: sqlite3.Connection, nome: str) -> bool: + """Se a tabela existe nesta instalacao do gateway. + + O schema do gateway cresce entre versoes. Perguntar antes de consultar e o + que faz um cartao cair para o estado vazio em vez de derrubar a pagina toda. + """ + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", (nome,) + ) + return cursor.fetchone() is not None + + +def get_all_api_keys(db_path: str) -> List[Dict[str, Any]]: + """Chaves virtuais emitidas pelo gateway (tabela ``apiKeys``), SEM o segredo. + + Chave virtual e o token que o cliente apresenta ao gateway no lugar da + credencial do provedor -- todo gateway da familia emite uma, e o painel + precisa mostrar quais existem e se ainda estao aceitas. + + A coluna ``key`` NAO entra na consulta: o material do token nao tem por que + sair do banco para desenhar uma tabela. Quem precisa dele para falar com o + gateway usa ``get_active_api_key``, que existe justamente para deixar esse + uso visivel num lugar so. + """ + conn = get_db_connection(db_path) + try: + if not _tabela_existe(conn, "apiKeys"): + return [] + + # So pede o que a instalacao realmente tem: uma coluna ausente faria a + # consulta inteira falhar e o cartao sumir da tela. + presentes = {linha[1] for linha in conn.execute("PRAGMA table_info(apiKeys)")} + colunas = [c for c in ("id", "name", "machineId", "isActive", "createdAt") 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 apiKeys"): + item = dict(linha) + resultado.append({ + "id": str(item.get("id") or ""), + "name": item.get("name") or "", + "machineId": item.get("machineId") or "", + "createdAt": item.get("createdAt"), + # Coluna ausente vira o padrao do proprio gateway (chave ativa). + # Assumir o contrario pintaria de vermelho toda chave de uma + # instalacao antiga. + "isActive": bool(item["isActive"]) if item.get("isActive") is not None else True, + }) + resultado.sort(key=lambda k: (k["name"] or k["id"]).lower()) + return resultado + finally: + conn.close() + + +def get_active_api_key(db_path: str) -> str: + """Uma chave ATIVA do gateway, e o unico lugar que le a coluna ``key``. + + Existe para um uso so: o cabecalho ``Authorization`` da leitura do catalogo + de modelos. O gateway monta ``/v1/models`` em tempo de requisicao, a partir + do registro estatico somado as conexoes vivas -- nao ha tabela de modelos + para ler, entao a rota HTTP e a unica fonte, e ela so responde a uma chave + que o proprio gateway emitiu. + + (O irmao cujo gateway guarda o catalogo o le direto do banco, no namespace + ``syncedAvailableModels``, e por isso nao precisa de chave nenhuma. A + diferenca e do gateway, nao de criterio.) + + O valor volta daqui apenas para virar cabecalho: nao e renderizado, nao + entra em log e nao passa por argv. String vazia quando nao ha chave ativa -- + o cartao cai para o estado vazio dizendo exatamente isso. + """ + conn = get_db_connection(db_path) + try: + if not _tabela_existe(conn, "apiKeys"): + return "" + cursor = conn.execute( + "SELECT key FROM apiKeys WHERE isActive = 1 ORDER BY createdAt ASC LIMIT 1" + ) + linha = cursor.fetchone() + return str(linha[0]) if linha and linha[0] else "" + except sqlite3.Error: + # Instalacao sem a coluna ``key``: sem chave, sem catalogo, sem queda. + return "" + finally: + conn.close() + + +def get_all_combos(db_path: str) -> List[Dict[str, Any]]: + """Return all registered combos from database.""" + conn = get_db_connection(db_path) + try: + cursor = conn.cursor() + cursor.execute("SELECT id, name, kind, models, updatedAt FROM combos ORDER BY name ASC") + rows = cursor.fetchall() + result = [] + for r in rows: + try: + models = json.loads(r["models"]) + except Exception: + models = [] + result.append({ + "id": r["id"], + "name": r["name"], + "kind": r["kind"], + "models": models, + "updatedAt": r["updatedAt"], + }) + return result + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Catálogo de modelos: HTTP, e não SQL +# --------------------------------------------------------------------------- +# +# A rota exige uma chave que o próprio gateway emitiu. Ela vem de +# `get_active_api_key`, vira cabeçalho `Authorization` e morre aqui: não é +# renderizada, não entra em log e não passa por argv. + +# Resultados possiveis da leitura. Sao tres porque "vazio" nao explica nada: o +# operador precisa distinguir "o gateway nao publica modelo nenhum" de "nao deu +# para perguntar", e a tela diz qual dos tres aconteceu. +CATALOGO_OK = "ok" +CATALOGO_SEM_CHAVE = "no_key" +CATALOGO_INACESSIVEL = "unreachable" + +# Timeout curto: a pagina e montada no servidor e o operador espera por ela. +CATALOG_TIMEOUT_SECONDS = 6.0 + +USER_AGENT = f"{NOME_DO_PRODUTO}/1.0" + +# O gateway publica os combos no MESMO catalogo, marcados com este dono. Eles +# ficam de fora daqui porque ja tem cartao proprio -- lista-los duas vezes faria +# a contagem de modelos mentir. +DONO_DE_COMBO = "combo" + + +def fetch_gateway_models( + router_url: str, + api_key: str, + timeout: float = CATALOG_TIMEOUT_SECONDS, + opener: Optional[Any] = None, +) -> Tuple[str, List[Dict[str, Any]]]: + """Le ``/v1/models`` do gateway e devolve ``(estado, entradas)``. + + Nunca levanta excecao: o painel inteiro nao pode cair porque o catalogo de + um cartao nao respondeu. + """ + if not router_url: + return CATALOGO_INACESSIVEL, [] + if not api_key: + return CATALOGO_SEM_CHAVE, [] + + url = f"{router_url.rstrip('/')}/v1/models" + requisicao = urllib.request.Request(url, method="GET") + requisicao.add_header("User-Agent", USER_AGENT) + requisicao.add_header("Authorization", f"Bearer {api_key}") + + enviar = opener or urllib.request.urlopen + try: + with enviar(requisicao, timeout=timeout) as resposta: + corpo = resposta.read().decode("utf-8", errors="replace") + dados = json.loads(corpo) if corpo.strip() else {} + except (urllib.error.URLError, OSError, ValueError): + # URLError cobre o HTTPError (401 de chave recusada, 5xx do gateway); + # ValueError, a resposta que nao e JSON. Em todos, o fato observavel e o + # mesmo: nao ha catalogo para mostrar. + return CATALOGO_INACESSIVEL, [] + + entradas = dados.get("data") if isinstance(dados, dict) else dados + if not isinstance(entradas, list): + return CATALOGO_INACESSIVEL, [] + + modelos = [] + for entrada in entradas: + if not isinstance(entrada, dict): + continue + identificador = entrada.get("id") + dono = str(entrada.get("owned_by") or "") + if not identificador or dono == DONO_DE_COMBO: + continue + modelos.append({ + "id": str(identificador), + "name": entrada.get("name") or str(identificador), + "provider": dono, + "source": entrada.get("root") or "", + "supportedEndpoints": entrada.get("supportedEndpoints") or [], + }) + + modelos.sort(key=lambda m: (m["provider"].lower(), m["id"].lower())) + return CATALOGO_OK, modelos + + +def build_registered_models( + entries: List[Dict[str, Any]], connections: List[ConnectionRecord] +) -> List[RegisteredModelRecord]: + """Amarra cada modelo a conexao que o serve, pelo nome do provedor. + + O ``owned_by`` que o gateway devolve e o alias do provedor ("groq", + "gemini"), o mesmo valor da coluna ``provider`` em ``providerConnections``. + Quando nao ha conexao com aquele nome, o modelo vem do registro estatico do + gateway e fica sem dona -- e ai a linha diz "nao verificado" em vez de + herdar a saude de alguem. + """ + por_provedor: Dict[str, ConnectionRecord] = {} + for conexao in connections: + chave = str(conexao.provider or "").lower() + if chave and chave not in por_provedor: + por_provedor[chave] = conexao + + return [ + RegisteredModelRecord.from_entry( + entrada, por_provedor.get(str(entrada.get("provider") or "").lower()) + ) + for entrada in entries + ] + + +# --------------------------------------------------------------------------- +# A costura: a única função que `web.py` chama para saber o que desenhar +# --------------------------------------------------------------------------- + + +def sondar(router_url: str) -> Dict[str, Any]: + """Diz se o gateway responde, e em quanto tempo. + + Sem cache: quem decide guardar o resultado é `web.py`, que é comum aos três. + Aqui mora só o que é específico deste gateway -- o endereço que se pergunta + e o que conta como "respondeu". + """ + if not router_url: + return {"url": router_url, "online": True, "statusCode": 200, "latencyMs": 0} + comeco = time.time() + try: + pedido = urllib.request.Request( + router_url, headers={"User-Agent": f"{NOME_DO_PRODUTO}-Healthcheck/1.0"} + ) + with urllib.request.urlopen(pedido, timeout=TIMEOUT_DE_SONDAGEM) as resposta: + codigo = resposta.status + except urllib.error.HTTPError as erro: + codigo = erro.code + except Exception: + codigo = 0 + return { + "url": router_url, + "online": 0 < codigo < 500, + "statusCode": codigo, + "latencyMs": int((time.time() - comeco) * 1000), + } + + +def carregar_painel(settings: Any) -> Dict[str, Any]: + """Lê deste gateway tudo o que a tela precisa, com as oito chaves de sempre. + + Assinatura fixa nos três irmãos, e as MESMAS oito chaves sempre presentes: + + connections conexões de provedor cadastradas + keys chaves virtuais emitidas pelo gateway, sem o segredo + models catálogo de modelos que o gateway publica + combos combos de resiliência e fallback + findings achados de coerência de limites -- vazio aqui: este + gateway não tem orçamento nem teto por chave para conferir + counters quantidades já contadas, para o cartão de métricas + probe resultado da sondagem, ou vazio se ninguém sondou + model_states estado da leitura do catálogo, por fonte + + Uma chave que este gateway não tem vem VAZIA, e nunca ausente: a tela + desenha o cartão em estado vazio, que é comportamento que os três já têm. + Omitir a chave transformaria a ausência de dado num `KeyError` no render. + """ + db_path = getattr(settings, "db_path", "") or "" + router_url = getattr(settings, "router_url", "") or "" + banco_existe = bool(db_path and os.path.exists(db_path)) + + conexoes: List[Any] = [] + combos: List[Dict[str, Any]] = [] + chaves: List[Any] = [] + if banco_existe: + try: + conexoes = get_all_connections(db_path) + except Exception: + conexoes = [] + try: + combos = get_all_combos(db_path) + except Exception: + combos = [] + try: + chaves = [VirtualKeyRecord.from_row(linha) for linha in get_all_api_keys(db_path)] + except Exception: + chaves = [] + + estado_do_catalogo, entradas = ler_catalogo(router_url, db_path) + modelos = build_registered_models(entradas, conexoes) + + return { + "connections": conexoes, + "keys": chaves, + "models": modelos, + "combos": combos, + # Este gateway não guarda orçamento nem teto por chave: não há o que + # conferir, e a lista vazia é a resposta honesta. + "findings": [], + "counters": { + "connections": len(conexoes), + "keys": len(chaves), + "models": len(modelos), + "combos": len(combos), + "findings": 0, + }, + # Quem sonda é `web.py`, que guarda o resultado em cache; aqui a chave + # existe para que o formato do retorno seja o mesmo nos três. + "probe": {}, + "model_states": {"catalog": estado_do_catalogo, "db": "ok" if banco_existe else "missing"}, + } + + +def esquece_o_catalogo() -> None: + """Descarta o catálogo memorizado para que a próxima leitura vá à rede. + + Chamado depois de uma ação do operador: sem isto, o painel continuaria a + mostrar por até dois minutos o estado anterior ao que ele acabou de fazer. + """ + with _trava_do_catalogo: + _catalogo_memorizado.clear() + + +def ler_catalogo(router_url: str, db_path: str) -> Tuple[str, List[Dict[str, Any]]]: + """Catálogo do gateway, memorizado por `TTL_DO_CATALOGO`. + + A chave que autentica a leitura vive só nesta pilha de chamada: entra no + cabeçalho `Authorization` dentro de `fetch_gateway_models` e NÃO é + memorizada -- o cache guarda apenas o resultado. + """ + if not db_path or not os.path.exists(db_path): + return CATALOGO_INACESSIVEL, [] + + agora = time.time() + with _trava_do_catalogo: + gravado_em, resultado = _catalogo_memorizado.get(router_url, (0.0, None)) + if resultado is not None and (agora - gravado_em) < TTL_DO_CATALOGO: + return resultado + + try: + resultado = fetch_gateway_models(router_url, get_active_api_key(db_path)) + except Exception: + # Banco travado, arquivo sumindo no meio da leitura: o cartao cai para o + # estado vazio em vez de levar a pagina inteira junto. + resultado = (CATALOGO_INACESSIVEL, []) + + with _trava_do_catalogo: + _catalogo_memorizado[router_url] = (agora, resultado) + return resultado diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py index 9af2a7c..425c350 100644 --- a/src/nine_rtksync/i18n.py +++ b/src/nine_rtksync/i18n.py @@ -1,14 +1,22 @@ -"""Internacionalização da interface do 9RTKSync. +"""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": "9Router 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": "9Router 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": "9Router 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/nine_rtksync/identidade.py b/src/nine_rtksync/identidade.py new file mode 100644 index 0000000..75c7006 --- /dev/null +++ b/src/nine_rtksync/identidade.py @@ -0,0 +1,99 @@ +"""Tudo o que diferencia este painel dos irmãos, e nada além disso. + +Os três sincronizadores -- 9RTKSync, OminiRTkSync e LiteLlmRTKSync -- são a +mesma aplicação. O que muda entre eles é cor, nome, logo, identidade visual e +com quem cada um conversa. Este arquivo é a fronteira: depois dele, nenhum outro +módulo do pacote escreve uma cor em hexadecimal, o nome do produto, o nome do +gateway, o prefixo de container ou o ícone da marca. Quem precisar de um desses +valores importa daqui. + +A fronteira existe para poder ser verificada. `tests/test_identidade_isolada.py` +varre o pacote inteiro e reprova qualquer literal de identidade fora deste +arquivo; `tests/test_irmaos_identicos.py` compara byte a byte os módulos comuns +com os dos irmãos ao lado. Sem este arquivo os dois testes seriam impossíveis: +a divergência ficaria diluída em vinte e três arquivos, onde ninguém a vê. + +Sem NENHUM import interno, de propósito: qualquer módulo pode importar daqui sem +risco de ciclo, inclusive os que o próprio `i18n` usa. +""" + +# Nome do produto, como aparece na aba, no cabeçalho do painel e no `User-Agent`. +NOME_DO_PRODUTO = "9RTKSync" + +# O gateway com quem este produto conversa, no texto que o operador lê. +NOME_DO_GATEWAY = "9Router" + +# O mesmo gateway como slug, na coluna "Provedor" da tabela de chaves virtuais: +# quem emitiu a chave foi o próprio gateway, e deixar a célula vazia +# desalinharia a tabela das outras duas, que usam a mesma casca. +PROVEDOR_DO_GATEWAY = "9router" + +# Prefixo dos containers desta pilha (`9rtk-app`, `9rtk-gateway`, ...). +PREFIXO_DE_CONTAINER = "9rtk-" + +# Portas padrão. `config.py` continua lendo o ambiente: isto é só o valor de +# fábrica, não uma segunda fonte de verdade. +PORTA_DO_PAINEL = 8081 +PORTA_DE_METRICAS = 9091 + +# Ícone da marca, do conjunto Bootstrap Icons. Nunca emoji. +ICONE_DO_PRODUTO = "bi-lightning-charge-fill" + +# Desenho do ícone da aba: o(s) elemento(s) de dentro do . A moldura +# (viewBox, rect, transform) é comum aos três e fica em render.py. Vai o markup +# inteiro, e não só o atributo `d`, porque um dos irmãos desenha com dois +# : guardar só o `d` faria o template do favicon deixar de ser o mesmo +# texto nos três. +GLIFO_DO_FAVICON = ( + "" +) + +# Fundo do favicon, já escapado para caber numa data URI. É o token `--surface`, +# e não o `--bg`: o ícone é um cartão sobre a aba, não o fundo da página. +COR_DO_FAVICON = "%23102422" + +# Os nove papéis cromáticos. Existem nos três painéis com valores diferentes; +# o que NÃO pode mudar é o conjunto de papéis, porque um token a mais é um +# componente que só um painel sabe desenhar. Os tokens estruturais (`--text` e +# os `--bs-*` que fazem o Bootstrap obedecer) ficam em `render.py` com o mesmo +# valor nos três: não são identidade. +PALETA = { + "--bg": "#091413", # fundo da página + "--surface": "#102422", # cartão + "--surface-2": "#16302d", # cabeçalho de cartão, chip + "--line": "#1e413d", # borda + "--accent": "#2fb8a4", # ação primária + "--accent-2": "#57d6c4", # ação secundária, realce + "--brand-a": "#12806f", # marca, início do gradiente + "--brand-b": "#2fb8a4", # marca, fim do gradiente + "--text-dim": "#9fb8b3", # texto secundário +} + +# Nomes dos cookies. Levam o slug do produto para que os três painéis possam +# rodar no mesmo navegador, no mesmo loopback, sem um derrubar a sessão do outro. +NOME_DO_COOKIE = "9rtksync_sessao" +NOME_DO_COOKIE_DE_ESTADO = "9rtksync_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/nine_rtksync/logs.py b/src/nine_rtksync/logs.py index 2325f23..f7755cf 100644 --- a/src/nine_rtksync/logs.py +++ b/src/nine_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 ~/.9rtksync/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 = "9rtksync.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("~"), ".9rtksync", "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("9rtksync") + 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/nine_rtksync/models.py b/src/nine_rtksync/models.py index a210071..c96ee01 100644 --- a/src/nine_rtksync/models.py +++ b/src/nine_rtksync/models.py @@ -1,39 +1,155 @@ -"""Data models for connections, credentials, and health states in 9Router.""" +"""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 typing import Any, Dict, Optional +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +# 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 + +# 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") + + +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: - """Represents a row from the providerConnections table in 9Router SQLite.""" - id: str - provider: str - name: str - created_at: str - updated_at: str - data_raw: 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": + """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", "")), + name=str(row.get("name") or row.get("provider") or ""), + data=dict(row), + ) + @property def is_oauth(self) -> bool: - """Check whether the connection uses an OAuth token flow.""" + """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: - """Check whether the connection is authenticated via static API key.""" + """Se a conexão se autentica por chave de API estática.""" return bool(self.data.get("apiKey")) + @property + def api_key(self) -> Optional[str]: + return self.data.get("apiKey") + @property def access_token(self) -> Optional[str]: return self.data.get("accessToken") @@ -43,64 +159,98 @@ def refresh_token(self) -> Optional[str]: return self.data.get("refreshToken") @property - def api_key(self) -> Optional[str]: - return self.data.get("apiKey") + def base_url(self) -> Optional[str]: + """Endereço do provedor, onde quer que o gateway o tenha guardado. + + 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): + nested = specific.get("baseUrl") or specific.get("baseURL") + if nested: + return nested + return ( + self.data.get("baseUrl") + or self.data.get("baseURL") + or self.data.get("base_url") + or None + ) - # Provider names that suggest a local / OpenAI-compatible instance. A marker - # alone is not proof: "ollama" is also the name of Ollama Cloud, which is a - # hosted service and must never be probed on /api/tags. - LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") - LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal", ".local") + @property + def 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: - """Whether the connection really points at an instance on this machine. + """Se a conexão realmente aponta para uma instância nesta máquina. - 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. + 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 self.LOCAL_HOSTS) - - # No address: "openai-compatible" only exists as a self-hosted endpoint, - # whereas "ollama" without a baseUrl is the cloud account. + 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 base_url(self) -> Optional[str]: - """Provider base URL, when declared. - - 9Router keeps it inside providerSpecificData, not at the root of data -- - reading only the root is why local instances used to show no models. - """ - specific = self.data.get("providerSpecificData") - if isinstance(specific, dict): - nested = specific.get("baseUrl") or specific.get("baseURL") - if nested: - return nested - return self.data.get("baseUrl") or self.data.get("baseURL") or None - - @property - def local_models(self) -> list: - """Models discovered on the local instance during the last sweep.""" + def local_models(self) -> List[str]: + """Modelos descobertos na instância local na última varredura.""" models = self.data.get("discoveredModels") or self.data.get("models") or [] if isinstance(models, str): return [models] return [str(m) for m in models if m] @property - def egress_binding(self) -> Optional[str]: - """Proxy pool this connection egresses through, when one is bound. - - Read-only: the binding is owned by the gateway, and 9Router keeps it in - ``providerSpecificData`` as ``proxyPoolId`` plus the per-connection - ``connectionProxyEnabled`` switch. It is surfaced here because an - account that shares one outbound address with every other account is - the state operators most want to notice, and nothing in the panel used - to show it. + 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 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 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 @@ -109,49 +259,32 @@ def egress_binding(self) -> Optional[str]: pool = specific.get("proxyPoolId") return str(pool) if pool else None - @property - def egress_status(self) -> str: - """One of: ``bound`` (own pool), ``shared`` (gateway default), ``unknown``.""" - specific = self.data.get("providerSpecificData") - if not isinstance(specific, dict): - return "unknown" - if specific.get("connectionProxyEnabled") is True and specific.get("proxyPoolId"): - return "bound" - return "shared" - @property def expires_at_ms(self) -> Optional[int]: - """Return normalized expiration timestamp in epoch milliseconds, if applicable.""" - val = self.data.get("expiresAt") - if isinstance(val, (int, float)) and val > 0: - # If epoch is in seconds (e.g. 1.7e9), convert to milliseconds - if val < 1e11: - return int(val * 1000) - return int(val) - return None + """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]: - """Seconds remaining before credential expires.""" - 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 - now_ms = int(time.time() * 1000) - return int((exp - now_ms) / 1000) + return int((expires - int(time.time() * 1000)) / 1000) @property def is_expired(self) -> bool: - """Check whether the credential has already expired.""" - rem = self.remaining_seconds - return rem is not None and rem <= 0 + """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") @@ -162,66 +295,502 @@ def last_refresh_at(self) -> Optional[str]: @property def credential_state(self) -> Optional[str]: - """Result of the last live credential probe, when one was recorded. + """Resultado da última validação viva da credencial, quando houve uma. - Written by credential_check.py, never by the gateway. + Quem escreve este campo é a sondagem deste painel, nunca o gateway. """ state = self.data.get("credentialState") return str(state) if state else None @property def rate_limit_active(self) -> bool: - """Whether the rate-limit hold is still in force right now. + """Se a trava de rate limit ainda vale neste instante. - `rateLimitedUntil` guarda o instante em que a janela do provedor se - reabre -- é um prazo, não uma bandeira. Tratar a mera presença do campo + ``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, já que nada apaga a marca quando o prazo vence. + 429, porque nada apaga a marca quando o prazo vence. """ - from .normalizer import parse_iso_or_str_to_ms - - until = parse_iso_or_str_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 connection health. + """Classificação semântica do estado da conexão. - A live probe outranks everything else: a key the provider rejects is - broken no matter what the gateway last stamped. Local instances are - classified before the API-key branch because they carry a facade key - and would otherwise never reach their own test. + 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: - # A local instance is only healthy when its model catalog answered. - estado = self.data.get("testStatus") - if estado == "unreachable": - return "unknown" - # Conexao recem-criada nunca foi sondada, e com CRON_ENABLED=0 pode - # nunca ser: dizer "ativa" e alegar uma saude que ninguem verificou. - 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: - rem = self.remaining_seconds - if rem is None: - return "no_expiration" - if rem <= 0: - return "expired" - if rem < 900: - return "expiring_soon" - return "active" + # 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 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 HEALTH_INVALID + remaining = self.remaining_seconds + if remaining is None: + return HEALTH_NO_EXPIRATION + if remaining <= 0: + return HEALTH_EXPIRED + if remaining < EXPIRING_SOON_SECONDS: + return HEALTH_EXPIRING_SOON + return HEALTH_ACTIVE if self.has_api_key: if self.rate_limit_active: - return "rate_limited" - # Never probed yet: say so instead of claiming health nobody verified. - 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) - # 9Router writes "ok", OmniRoute writes "active"; both mean healthy. - return "active" if self.data.get("testStatus") in ("ok", "active", "success") else "unknown" + @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. + + 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/nine_rtksync/normalizer.py b/src/nine_rtksync/normalizer.py index 39098b0..aa0d6fd 100644 --- a/src/nine_rtksync/normalizer.py +++ b/src/nine_rtksync/normalizer.py @@ -1,7 +1,7 @@ -"""Self-healing and format normalization for credentials and rate-limit locks in 9Router SQLite.""" +"""Self-healing and format normalization for credentials and rate-limit locks in the gateway store.""" import time -from datetime import datetime, timezone +from datetime import datetime, timezone, timezone from typing import Any, Dict, List, Optional, Tuple @@ -31,9 +31,17 @@ def parse_iso_or_str_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 @@ -41,7 +49,7 @@ def parse_iso_or_str_to_ms(val: Any) -> Optional[int]: def normalize_connection_data(raw_data: Dict[str, Any]) -> Tuple[bool, Dict[str, Any], List[str]]: """ Inspect connection data dictionary and apply self-healing: - 1. Fix expiresAt stored as an ISO string by 9Router to numeric epoch in ms. + 1. Fix expiresAt stored as an ISO string by the gateway to numeric epoch in ms. 2. Remove rate-limit locks (rateLimitedUntil) if cooldown has elapsed. 3. Clear legacy backoffLevel penalties. diff --git a/src/nine_rtksync/paginacao.py b/src/nine_rtksync/paginacao.py new file mode 100644 index 0000000..b1bd3dd --- /dev/null +++ b/src/nine_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/nine_rtksync/prefs.py b/src/nine_rtksync/prefs.py index 3aeb855..a418c69 100644 --- a/src/nine_rtksync/prefs.py +++ b/src/nine_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 9Router 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/nine_rtksync/protecao.py b/src/nine_rtksync/protecao.py new file mode 100644 index 0000000..f7610e2 --- /dev/null +++ b/src/nine_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/nine_rtksync/providers/__init__.py b/src/nine_rtksync/providers/__init__.py index bdfd866..dcd238a 100644 --- a/src/nine_rtksync/providers/__init__.py +++ b/src/nine_rtksync/providers/__init__.py @@ -1,4 +1,4 @@ -"""Credential synchronization and auto-renewal providers for 9RTKSync.""" +"""Credential synchronization and auto-renewal providers for this synchronizer.""" from .base import BaseProvider from .google import GoogleProvider diff --git a/src/nine_rtksync/providers/google.py b/src/nine_rtksync/providers/google.py index f6fbad5..122e134 100644 --- a/src/nine_rtksync/providers/google.py +++ b/src/nine_rtksync/providers/google.py @@ -73,7 +73,7 @@ def discover_client_secrets(self, conn: ConnectionRecord) -> Tuple[str, str]: client_id = local.get("client_id") or local.get("clientId") or client_id client_secret = local.get("client_secret") or local.get("clientSecret") or client_secret - # Fallback: discover in 9Router provider files if running in the same container/volume + # Fallback: discover in the gateway provider files if running in the same container/volume data_dir = os.environ.get("DATA_DIR", "/app/data") candidate_files = [ os.path.join(data_dir, "shared.js"), diff --git a/src/nine_rtksync/providers/local.py b/src/nine_rtksync/providers/local.py index 690240c..7597313 100644 --- a/src/nine_rtksync/providers/local.py +++ b/src/nine_rtksync/providers/local.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple +from ..identidade import NOME_DO_PRODUTO from ..models import ConnectionRecord from .base import BaseProvider @@ -40,7 +41,7 @@ def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], for path in MODEL_CATALOG_PATHS: target = f"{origin}{path}" if path.startswith("/api") else f"{root}{path}" try: - req = urllib.request.Request(target, headers={"User-Agent": "9RTKSync-LocalProbe/1.0"}) + 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: diff --git a/src/nine_rtksync/render.py b/src/nine_rtksync/render.py new file mode 100644 index 0000000..b8f7e6f --- /dev/null +++ b/src/nine_rtksync/render.py @@ -0,0 +1,1888 @@ +"""Renderização server-side do dashboard. + +Todo o HTML é montado aqui, no servidor, com os dados já embutidos. O navegador +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. +""" + +import html +from datetime import datetime, timezone +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" +FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" +BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" +JQUERY_JS = "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" +# Tipografia: Google Fonts, com pilha de sistema como reserva se o CDN cair. +GOOGLE_FONTS = ( + "https://fonts.googleapis.com/css2?" + "family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" +) +FONT_STACK = "'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" +MONO_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace" + +# 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. + "invalid": ("text-bg-danger", "bi-shield-exclamation"), + "unreachable": ("text-bg-warning", "bi-plug"), + "not_checked": ("text-bg-secondary", "bi-dash-circle"), +} + + +def esc(value: Any) -> str: + """Escapa qualquer valor para inserção segura no HTML.""" + return html.escape(str(value if value is not None else ""), quote=True) + + +def format_duration(seconds: Optional[int], lang: str = DEFAULT_LANGUAGE) -> str: + """Formata uma duração em segundos de forma legível.""" + if seconds is None: + return translate("duration.unlimited", lang) + if seconds <= 0: + return translate("duration.expired", lang) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes} min" + hours = minutes // 60 + rest = minutes % 60 + if hours < 24: + return f"{hours}h {rest:02d}min" + days = hours // 24 + return f"{days}d {hours % 24}h" + + +def format_timestamp(value: Optional[str]) -> str: + """Normaliza um timestamp ISO para exibição. + + 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 "—" + 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: + """Explica, em uma frase, por que a conexão foi ou não renovada. + + Sem isso o painel mostra apenas "0 renovadas" e não há como distinguir + "nada precisava ser renovado" de "a renovação falhou". + """ + if conn.is_local: + # Quem diz se a instância respondeu é a sonda, não o tamanho do + # catálogo: uma instalação nova, de pé e sem nenhum modelo baixado, + # devolve lista vazia com HTTP 200. Contar modelos aqui a anunciava + # como inalcançável, contradizendo o "ativa" que o próprio ciclo + # acabara de gravar no banco. + if conn.data.get("testStatus") == "unreachable": + return translate("reason.local_unreachable", lang) + models = conn.local_models + if models: + return translate("reason.local_ok", lang, count=len(models)) + return translate("reason.local_empty", lang) + + if not conn.is_oauth: + return translate("reason.api_key", lang) + + remaining = conn.remaining_seconds + if remaining is None: + return translate("reason.no_expiry", lang) + if remaining <= 0: + return translate("reason.expired", lang) + + margin_min = max(1, refresh_margin // 60) + if remaining <= refresh_margin: + return translate("reason.inside_margin", lang, margin=margin_min) + return translate( + "reason.outside_margin", + lang, + margin=margin_min, + eta=format_duration(remaining - refresh_margin, lang), + ) + + +def render_last_refresh(conn: Any, lang: str) -> str: + """Mostra quando a credencial foi renovada pela ultima vez, e ha quanto tempo.""" + stamp = conn.last_refresh_at + if not stamp: + return f'{esc(translate("table.never_refreshed", lang))}' + + ago = "" + try: + moment = datetime.fromisoformat(str(stamp).replace("Z", "+00:00")) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + elapsed = int((datetime.now(timezone.utc) - moment).total_seconds()) + if elapsed >= 0: + ago = translate("table.time_ago", lang, elapsed=format_duration(elapsed, lang)) + except (ValueError, TypeError): + # Carimbo de tempo em formato desconhecido vira "sem informacao" na + # tela. Uma data ilegivel nao pode derrubar a renderizacao da pagina. + pass + + icon = '' + detail = f'
    {esc(ago)}
    ' if ago else "" + return f'{icon}{esc(format_timestamp_curto(stamp))}{detail}' + + +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))}' + ) + # 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: + """Marca de saída de rede da conexão, em modo somente leitura. + + O risco de bloqueio não vem de várias sessões na mesma conta -- isso os + provedores aceitam -- e sim de várias contas saindo pelo mesmo endereço. + Por isso "compartilhada" só vira aviso a partir da segunda conta nessa + situação: sozinha, ela é a única dona daquele IP. + """ + estado = conn.egress_status + 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))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.single", lang))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.unknown", lang))}' + ) + + +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. + + O instante exato de emissao, a maquina a que a chave esta amarrada e o modo + de acesso sao diagnostico: espremidos na tabela empurrariam as colunas uteis + para fora da tela. O TOKEN nunca entra aqui -- ele sequer e lido do banco. + """ + nao_declarado = f'{esc(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))}'), + # Quando o gateway nao guarda restricao de modelo por chave, toda chave + # que ele emite alcanca o catalogo inteiro -- e dizer isso e mais util + # do que omitir a linha e deixar a pergunta em aberto. + (translate("table.model_access", lang), esc(translate("keys.access_all", lang))), + (translate("table.source", lang), + f'{esc(key.machine_id)}' if key.machine_id + else nao_declarado), + ] + return render_detail_modal(modal_id, key.name, linhas, lang) + + +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_model_details(model: Any, modal_id: str, lang: str) -> str: + """Modal com o que nao cabe na linha do modelo. + + 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))}' + 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.remaining", lang), render_remaining_seconds(model.remaining_seconds, lang)), + (translate("table.source", lang), + f'{esc(model.source)}' if model.source + else nao_declarado), + ] + extra = f'

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

    ' + 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_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, + 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 = [] + detalhes = [] + for c in visiveis: + if c.is_local: + kind, kind_icon = translate("type.local", lang), "bi-hdd-network" + elif c.is_oauth: + kind, kind_icon = translate("type.oauth", lang), "bi-person-badge" + elif c.has_api_key: + kind, kind_icon = translate("type.api_key", lang), "bi-key" + else: + kind, kind_icon = translate("type.local", lang), "bi-hdd-network" + + # Instancia local: mostra a origem e os modelos que ela realmente serve. + detail = "" + if c.is_local: + models = c.local_models + parts = [] + if c.base_url: + parts.append(f'{esc(c.base_url)}') + if models: + preview = ", ".join(models[:3]) + (f" (+{len(models) - 3})" if len(models) > 3 else "") + parts.append( + f'{len(models)} ' + f'{esc(translate("table.models", lang))} {esc(preview)}' + ) + if parts: + detail = f'
    {" · ".join(parts)}
    ' + else: + # 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}
    ' + + rows.append(f""" + + {esc(c.provider)} + {esc(c.name)}{detail} + + {esc(kind)} + + {health_badge(c.health_status, lang)} + {render_remaining(c, lang, curto=True)} + {render_last_refresh(c, lang)} + {detail_button(f"detalhe-{c.id}", lang)} + """) + detalhes.append(render_connection_details(c, refresh_margin, compartilhando, 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, 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 estado_vazio(translate("combos.empty", lang), + translate("combos.empty_hint", lang)) + + visiveis, estado = recortar(combos, "combos", consulta) + rows = [] + 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", "—"))}{chip} + {esc(preview) or "—"} + """) + + tabela = f""" +
    + + + + + + + + {"".join(rows)} + +
    {esc(translate("table.combo", lang))}{esc(translate("table.cascade", lang))}
    +
    """ + 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 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))}

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

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

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

    + +

    +
    +
    {body}
    +
    +
    """) + + # 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 = ( + translate("cron.active", lang, interval=cron.get("intervalSeconds", "—")) + if active + else translate("cron.disabled", lang) + ) + last = cron.get("lastResult") or {} + failed = bool(last) and (not last.get("success", True) or last.get("error")) + + # 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""" +
    +
    + + {esc(translate("cron.title", lang))} + +
    + +
    + +
    +
    +
    +
    +

    + {esc(state_text)} +

    +
    +
    {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 "")} +
    +
    +
    +
    """ + + +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) + elif online: + diagnosis = translate("gateway.diag_db_failed", lang) + else: + diagnosis = translate("gateway.diag_gateway_failed", 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""" +
    +
    + + {esc(translate("gateway.title", lang))} + +
    + +
    +
    +
    +
    +
    {esc(translate("gateway.gateway", lang))}
    +
    {esc(gateway.get("url") or "—")}
    +
    {esc(translate("gateway.status", lang))}
    +
    + {label} +
    +
    {esc(translate("gateway.latency", lang))}
    +
    {esc(gateway.get("latencyMs", "—"))} ms
    {bloco_do_banco} +
    +
    +
    """ + + +def _campo_de_texto(nome: str, rotulo: str, valor: str, ajuda: str = "", + placeholder: str = "", desabilitado: bool = False) -> str: + """Um campo de texto do formulario de SSO, com rotulo e ajuda traduzidos.""" + trava = " disabled" if desabilitado else "" + dica = f'
    {esc(ajuda)}
    ' if ajuda else "" + return f""" +
    + + + {dica} +
    """ + + +def render_sso_modal(sso_view: Optional[Dict[str, Any]], lang: str) -> str: + """Corpo do modal de configuracao do SSO: duas abas, e nenhum segredo de volta. + + 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 e reexibido. A tela diz apenas que existe um valor + guardado e oferece um campo para substitui-lo; salvar com o campo em branco + mantem o que ja esta la. + """ + dados = dict(sso_view or {}) + config = dict(dados.get("config") or {}) + tem_segredo = bool(dados.get("tem_segredo")) + do_ambiente = bool(dados.get("segredo_do_ambiente")) + desligado = bool(dados.get("desligado_por_ambiente")) + saml_ok = bool(dados.get("saml_disponivel")) + ligado = config.get("enabled", "") == "oidc" + + aviso_ambiente = ( + f""" +
    + +
    {esc(translate("sso.disabled_by_env", lang))}
    +
    """ + if desligado + else "" + ) + + estado_do_segredo = ( + translate("sso.secret_from_env", lang) + if do_ambiente + else ( + translate("sso.secret_stored", lang) + if tem_segredo + else translate("sso.secret_missing", lang) + ) + ) + + callback = dados.get("callback_url") or "" + bloco_callback = ( + f""" +
    + +
    {esc(callback)}
    +
    {esc(translate("sso.callback_help", lang))}
    +
    """ + if callback + else "" + ) + + aba_oidc = f""" +
    + + +
    {esc(translate("sso.enabled_help", lang))}
    +
    + {_campo_de_texto("base_url", translate("sso.base_url", lang), + config.get("base_url", ""), translate("sso.base_url_help", lang), + "https://painel.exemplo.com")} + {bloco_callback} + {_campo_de_texto("issuer", translate("sso.issuer", lang), + config.get("issuer", ""), translate("sso.issuer_help", lang), + "https://accounts.google.com")} + {_campo_de_texto("client_id", translate("sso.client_id", lang), + config.get("client_id", ""))} +
    + + +
    {esc(estado_do_segredo)} {esc(translate("sso.secret_keep_help", lang))}
    +
    + {_campo_de_texto("scopes", translate("sso.scopes", lang), + config.get("scopes", ""), translate("sso.scopes_help", lang))} + {_campo_de_texto("allowed_domains", translate("sso.allowed_domains", lang), + config.get("allowed_domains", ""), + translate("sso.allowlist_help", lang), "empresa.com,filial.com")} + {_campo_de_texto("allowed_emails", translate("sso.allowed_emails", lang), + config.get("allowed_emails", ""), "", "chefe@empresa.com")}""" + + aviso_saml = ( + translate("sso.saml_pending", lang) if saml_ok else translate("sso.saml_unavailable", lang) + ) + aba_saml = f""" +
    + +
    {esc(aviso_saml)}
    +
    + {_campo_de_texto("saml_idp_entity_id", translate("sso.idp_entity_id", lang), + "", "", "", desabilitado=True)} + {_campo_de_texto("saml_idp_sso_url", translate("sso.idp_sso_url", lang), + "", "", "", desabilitado=True)} + {_campo_de_texto("saml_idp_cert", translate("sso.idp_cert", lang), + "", translate("sso.idp_cert_help", lang), "", desabilitado=True)}""" + + return f""" + {aviso_ambiente} +

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

    +
    + +
    {esc(translate("sso.tunnel_warning", lang))}
    +
    +
    + +
    +
    {aba_oidc} +
    +
    {aba_saml} +
    +
    +
    +
    + + +
    {esc(translate("sso.current_password_help", lang))}
    +
    + +
    """ + + +def render_dashboard( + *, + # 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) + + metrics = "".join([ + metric_card(translate("metric.total_connections", lang), len(connections), "bi-diagram-2", "text-info"), + metric_card(translate("metric.oauth_accounts", lang), oauth_count, "bi-person-badge", "text-primary"), + metric_card(translate("metric.api_keys", lang), apikey_count, "bi-key", "text-warning"), + metric_card(translate("metric.combos", lang), len(combos), "bi-diagram-3", "text-success"), + ]) + + 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_view, lang) + + return f""" + + + + + + + {NOME_DO_PRODUTO} + + + + + + + + + +
    + + {render_flash(flash)} + {render_security_banner(is_default_password, lang)} + +
    +
    + +
    +

    {NOME_DO_PRODUTO}

    +

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

    +
    +
    +
    + {render_language_switcher(lang)} +
    + +
    + +
    + +
    +
    +
    + +
    {metrics} +
    + + +
    +
    {render_gateway_card(gateway, db_path, lang)}
    +
    {render_cron_card(cron, lang)}
    +
    + +
    +
    + + {esc(translate("connections.title", lang))} + + {len(connections)} +
    + {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))} + + {len(combos)} +
    + {tabela_de_combos} +
    + +
    + + {esc(translate("footer.signed_in", lang))} + {esc(current_user)} + + + + {esc(translate("footer.generated", lang))} + {esc(generated_at)} + +
    + {rodape_da_pathbit()} +
    + + + + + + + + + + + +""" diff --git a/src/nine_rtksync/sessao.py b/src/nine_rtksync/sessao.py new file mode 100644 index 0000000..3b81154 --- /dev/null +++ b/src/nine_rtksync/sessao.py @@ -0,0 +1,198 @@ +"""Sessão do painel: um cookie assinado, emitido por um formulário de login. + +O painel nasceu só com Basic Auth, e isso cobra três preços. O navegador abre um +diálogo próprio, fora da página, que não se pode estilizar nem traduzir; não há +logout, porque o navegador reenvia a credencial até fechar a janela; e qualquer +ferramenta que dirija um navegador trava no diálogo, que não é HTML. + +O Basic Auth continua aceito — é o que faz `curl` e scripts funcionarem sem +sessão. O que muda é que agora existe uma segunda porta: um formulário que +entrega um cookie assinado. + +O segredo que assina o cookie nasce a cada processo, em memória. Reiniciar o +serviço invalida as sessões abertas, o que é a escolha certa para um painel que +lê credenciais: não há sessão sobrevivendo a uma troca de senha ou a um +container recriado. +""" + +import base64 +import hashlib +import hmac +import os +import secrets +import time +from typing import Optional + +from .identidade import NOME_DO_COOKIE, NOME_DO_COOKIE_DE_ESTADO + +# Oito horas: um turno de trabalho. Depois disso o operador entra de novo. +VALIDADE_EM_SEGUNDOS = 8 * 60 * 60 + +# O estado do SSO dura o tempo de escolher a conta no provedor, e não mais. +VALIDADE_DO_ESTADO_EM_SEGUNDOS = 600 + +_SEGREDO = secrets.token_bytes(32) + + +def _assina(carga: str) -> str: + return hmac.new(_SEGREDO, carga.encode("utf-8"), hashlib.sha256).hexdigest() + + +def _assina_estado(carga: str) -> str: + """Assinatura do cookie de estado, com o MESMO segredo e domínio separado. + + O segredo é um só — dois segredos seriam duas coisas para rodar e uma para + esquecer. O prefixo é o que impede que um valor assinado para um dos dois + cookies seja aceito como o outro: sem ele, um estado de SSO forjado poderia + ser apresentado como cookie de sessão. + """ + return hmac.new( + _SEGREDO, b"estado-sso|" + carga.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + +def _assinatura_confere(apresentada: str, esperada: str) -> bool: + """Compara em tempo constante e em BYTES, sem levantar com texto hostil. + + A comparação não pode vazar, pelo tempo que leva, quantos caracteres do + início bateram. E tem de ser em bytes: o cabeçalho Cookie é decodificado em + latin-1, e um único byte acima de 0x7f na assinatura faria `compare_digest` + levantar TypeError — uma exceção não tratada numa rota pública, com o + conteúdo escolhido pelo visitante. + """ + return hmac.compare_digest( + str(apresentada or "").encode("utf-8", "surrogatepass"), + esperada.encode("ascii"), + ) + + +def emitir(usuario: str, agora: Optional[float] = None) -> str: + """Devolve o valor do cookie para um usuário já autenticado.""" + expira = int((agora if agora is not None else time.time()) + VALIDADE_EM_SEGUNDOS) + carga = f"{usuario}|{expira}" + codificada = base64.urlsafe_b64encode(carga.encode("utf-8")).decode("ascii") + return f"{codificada}.{_assina(carga)}" + + +def usuario_da_sessao(valor: str, agora: Optional[float] = None) -> Optional[str]: + """Devolve o usuário se o cookie for íntegro e estiver no prazo, senão None.""" + if not valor or "." not in valor: + return None + codificada, assinatura = valor.rsplit(".", 1) + try: + carga = base64.urlsafe_b64decode(codificada.encode("ascii")).decode("utf-8") + except Exception: + return None + if not _assinatura_confere(assinatura, _assina(carga)): + return None + if "|" not in carga: + return None + usuario, _, expira = carga.rpartition("|") + try: + if float(expira) < (agora if agora is not None else time.time()): + return None + except ValueError: + return None + return usuario or None + + +def cabecalho_para_gravar(valor: str) -> str: + """Cookie de sessão: inacessível ao script da página e presa a este site. + + Sem `Secure` de propósito: o painel é servido em HTTP no loopback, e um + cookie `Secure` simplesmente não seria gravado ali. + """ + return ( + f"{NOME_DO_COOKIE}={valor}; Path=/; HttpOnly; SameSite=Strict; " + f"Max-Age={VALIDADE_EM_SEGUNDOS}" + ) + + +def cabecalho_para_apagar() -> str: + return f"{NOME_DO_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + + +def ler_cookie(cabecalho_cookie: str, nome_procurado: str) -> str: + """Extrai um cookie pelo nome de um cabeçalho Cookie cru.""" + for parte in (cabecalho_cookie or "").split(";"): + nome, _, valor = parte.strip().partition("=") + if nome == nome_procurado: + return valor + return "" + + +def ler_do_cabecalho(cabecalho_cookie: str) -> str: + """Extrai o valor do nosso cookie de sessão de um cabeçalho Cookie cru.""" + return ler_cookie(cabecalho_cookie, NOME_DO_COOKIE) + + +# --- Cookie de estado do SSO ------------------------------------------------ +# +# Fica aqui, e não num módulo próprio, para que o segredo que assina continue +# sendo UM só por processo: dois segredos seriam duas superfícies para manter +# em dia, e a segunda é sempre a que alguém esquece de rotacionar. + + +def emitir_estado_sso( + state: str, nonce: str, verificador: str, agora: Optional[float] = None +) -> str: + """Assina o estado da ida ao provedor de identidade. + + Os três valores nascem de `secrets.token_urlsafe`, que não produz `|`: o + separador é seguro, e um valor que o contenha não sobreviveria à leitura. + """ + expira = int( + (agora if agora is not None else time.time()) + VALIDADE_DO_ESTADO_EM_SEGUNDOS + ) + carga = f"{state}|{nonce}|{verificador}|{expira}" + codificada = base64.urlsafe_b64encode(carga.encode("utf-8")).decode("ascii") + return f"{codificada}.{_assina_estado(carga)}" + + +def ler_estado_sso(valor: str, agora: Optional[float] = None) -> Optional[dict]: + """Devolve o estado se o cookie for íntegro e estiver no prazo, senão None.""" + if not valor or "." not in valor: + return None + codificada, assinatura = valor.rsplit(".", 1) + try: + carga = base64.urlsafe_b64decode(codificada.encode("ascii")).decode("utf-8") + except Exception: + return None + if not _assinatura_confere(assinatura, _assina_estado(carga)): + return None + partes = carga.split("|") + if len(partes) != 4: + return None + state, nonce, verificador, expira = partes + try: + if float(expira) < (agora if agora is not None else time.time()): + return None + except ValueError: + return None + if not state or not nonce or not verificador: + return None + return {"state": state, "nonce": nonce, "verificador": verificador} + + +def cabecalho_para_gravar_estado(valor: str) -> str: + """Cookie de estado do SSO: `Lax`, curto e restrito ao caminho do fluxo. + + NÃO pode ser `SameSite=Strict` como o de sessão: a volta do provedor é uma + navegação vinda de outro site, e um cookie `Strict` simplesmente não é + enviado nela — a falha apareceria como "login que não funciona", sem erro + nenhum na tela. Sem `Secure` pelo mesmo motivo do cookie de sessão: o painel + é servido em HTTP no loopback. + """ + return ( + f"{NOME_DO_COOKIE_DE_ESTADO}={valor}; Path=/sso/; HttpOnly; SameSite=Lax; " + f"Max-Age={VALIDADE_DO_ESTADO_EM_SEGUNDOS}" + ) + + +def cabecalho_para_apagar_estado() -> str: + """Consumo de uso único: o mesmo `Path` do cookie, ou o navegador não o apaga.""" + return f"{NOME_DO_COOKIE_DE_ESTADO}=; Path=/sso/; HttpOnly; SameSite=Lax; Max-Age=0" + + +def ler_estado_do_cabecalho(cabecalho_cookie: str) -> str: + return ler_cookie(cabecalho_cookie, NOME_DO_COOKIE_DE_ESTADO) diff --git a/src/nine_rtksync/sso.py b/src/nine_rtksync/sso.py new file mode 100644 index 0000000..614f20d --- /dev/null +++ b/src/nine_rtksync/sso.py @@ -0,0 +1,1214 @@ +"""Entrada federada no painel: OIDC e SAML2, UM provedor por vez. + +O painel continua tendo a porta de sempre — usuário e senha locais, mais a +credencial de recuperação. O SSO é uma porta A MAIS, nunca a única: se o +provedor de identidade cair, quem tem a senha local entra do mesmo jeito, e +`SSO_DISABLED=1` desliga tudo sem tocar no banco. + +Três gavetas, separadas por sensibilidade: + +1. **O que não é segredo** vai no SQLite de preferências, com o prefixo `sso.` + — é a mesma tabela onde já moram o idioma e o usuário do painel. +2. **O segredo do cliente OIDC** vai num arquivo 0600 ao lado da credencial de + recuperação, NUNCA no SQLite. Sem disco gravável o SSO não liga: ligar sem + segredo seria anunciar uma porta que não abre. +3. **O ambiente vence**: `OIDC_CLIENT_SECRET` tem precedência sobre o arquivo, e + quando é ele quem manda a tela trava o campo, como já acontece com + `DASHBOARD_PASSWORD`. + +Por que não cifrar o segredo no banco: cifra reversível precisa de chave, e a +chave ficaria num arquivo ao lado do próprio banco, no mesmo volume. O modelo de +ataque é idêntico ao do arquivo 0600 puro, com o custo de uma AEAD artesanal — +a imagem não tem `cryptography`. A proteção real aqui é a permissão e o +isolamento do volume; dizer "cifrado em repouso" com a chave ao lado é teatro. + +Sobre a assinatura do `id_token`: ela NÃO é verificada localmente, e isso é +escolha, não atalho. O token chega pelo canal direto entre este processo e o +`token_endpoint`, sobre TLS com certificado verificado e com o cliente +autenticado — o caso que a OIDC Core 3.1.3.7, item 6, dispensa de verificação. +A alternativa exigiria escrever PKCS#1 v1.5 à mão, porque a biblioteca padrão +não verifica RSA e a imagem não tem `cryptography`; é exatamente na conferência +de padding que essas implementações falham em silêncio, aceitando assinatura +forjada. Em troca, o `sub` é ancorado no `userinfo_endpoint`, que só responde a +quem apresenta o `access_token` recebido na mesma troca. +""" + +import base64 +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import zlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Dict, Optional, Tuple + +# A descoberta é o único passo do fluxo que fala com o provedor sem ninguém +# esperando na frente da tela: quando ela falha, o botão some do login e o +# motivo não chega a lugar nenhum se não for registrado aqui. +_log = logging.getLogger(__name__) + +# -- chaves de configuração -------------------------------------------------- +# +# Prefixo `sso.` no MESMO banco de preferências do idioma e do usuário do +# painel. Nada aqui é segredo: são endereços públicos e uma lista de quem pode +# entrar. +CHAVE_PROVEDOR = "sso.enabled" +CHAVE_BASE_URL = "sso.base_url" +CHAVE_OIDC_ISSUER = "sso.oidc.issuer" +CHAVE_OIDC_CLIENT_ID = "sso.oidc.client_id" +CHAVE_OIDC_SCOPES = "sso.oidc.scopes" +CHAVE_SAML_IDP_ENTITY_ID = "sso.saml.idp_entity_id" +CHAVE_SAML_IDP_SSO_URL = "sso.saml.idp_sso_url" +CHAVE_SAML_IDP_CERT = "sso.saml.idp_cert" +CHAVE_DOMINIOS = "sso.allowed_domains" +CHAVE_EMAILS = "sso.allowed_emails" +CHAVE_ATUALIZADO_EM = "sso.updated_at" + +ARQUIVO_DO_SEGREDO = ".sso_client_secret" +# Os nomes das duas variáveis de ambiente. Eles aparecem escritos por extenso +# em `os.environ.get` logo abaixo, e não como constante: a guarda que confere se +# o `.env.example` documenta tudo o que o código lê varre a fonte atrás do +# literal, e uma constante a deixaria cega justamente para a variável que +# carrega um segredo. A repetição é deliberada, e o teste de SSO compara as duas +# formas. +VARIAVEL_DO_SEGREDO = "OIDC_CLIENT_SECRET" +VARIAVEL_DE_DESLIGAMENTO = "SSO_DISABLED" + +# Os dois tempos de espera do fluxo, nomeados. +# Espalhados como `5.0` e `10.0` no meio das chamadas, eles são a primeira coisa +# que alguém esquece de rever — e um pedido sem limite pendura a thread que +# serve o painel até o provedor devolver algo. +TIMEOUT_DESCOBERTA = 5.0 +TIMEOUT_TOKEN = 10.0 + +# Os endereços que não põem um byte na rede. +# Declarados como conjunto, e não embutidos na checagem, porque é esta lista que +# define a ÚNICA exceção ao HTTPS obrigatório. +HOSTS_DE_LOOPBACK = {"127.0.0.1", "localhost", "::1", "[::1]"} + +ESCOPOS_PADRAO = "openid email profile" + +# Caminhos das rotas. Ficam aqui para que a configuração, a tela e o servidor +# nunca discordem sobre qual é a URL de retorno registrada no provedor. +ROTA_OIDC_INICIAR = "/sso/oidc/iniciar" +ROTA_OIDC_CALLBACK = "/sso/oidc/callback" +ROTA_SAML_INICIAR = "/sso/saml/iniciar" +ROTA_SAML_ACS = "/sso/saml/acs" +ROTA_SAML_METADATA = "/sso/saml/metadata" + +# Tolerância de relógio do `iat`. Container com hora fora do lugar derruba todo +# o fluxo, e o sintoma parece "SSO quebrado" em vez de "relógio errado". +TOLERANCIA_DE_RELOGIO = 300 + +# Descoberta: uma leitura por hora, por processo. A FALHA também é guardada, +# por um minuto: a tela de login pergunta ao emissor para saber se desenha o +# botão, e sem esse negativo curto cada carregamento da tela com o provedor +# fora do ar pagaria o tempo de espera inteiro -- a página que tem de +# continuar de pé quando o provedor cai seria a primeira a parar. +VALIDADE_DA_DESCOBERTA = 3600 +VALIDADE_DA_FALHA_DE_DESCOBERTA = 60 + +# Conjunto de pendentes do SAML: dez minutos e teto duro. `/sso/saml/iniciar` é +# pública, e estado de servidor criado por rota pública sem teto é consumo de +# memória ilimitado à distância de um laço `while true`. +VALIDADE_DO_PENDENTE = 600 +LIMITE_DE_PENDENTES = 500 + +_trava = threading.Lock() +_descobertas: Dict[str, Tuple[float, Optional[dict]]] = {} +_pendentes: Dict[str, float] = {} +_assercoes_consumidas: Dict[str, float] = {} + + +class FalhaDeSSO(Exception): + """Falha de qualquer passo do fluxo federado. + + O motivo fica AQUI, para o log interno. A tela recebe sempre a mesma + mensagem genérica: distinguir "state trocado" de "e-mail fora da lista" + conta ao atacante em que ponto do fluxo ele parou. + + O motivo também fica em `detalhe`, e não só no texto da exceção: quem + escreve no log pede `erro.detalhe` sem depender de `str(erro)`, que muda de + forma no dia em que alguém acrescentar um argumento à exceção. + """ + + def __init__(self, detalhe: str): + super().__init__(detalhe) + self.detalhe = detalhe + + +# --------------------------------------------------------------------------- +# Ambiente +# --------------------------------------------------------------------------- + +def desligado_por_ambiente() -> bool: + """`SSO_DISABLED=1` vence o banco, sem tocar no SQLite. + + É o interruptor de emergência: quando o provedor cai e o painel está atrás + de um túnel, subir o container com a variável devolve o formulário local. + O padrão é "0" de propósito -- o auxiliar `_flag` do config assume "1" e + usá-lo aqui desligaria o SSO em toda instalação. + """ + return os.environ.get("SSO_DISABLED", "0").strip().lower() in ( + "1", "true", "yes", "on", "sim", + ) + + +def segredo_do_ambiente() -> str: + return os.environ.get("OIDC_CLIENT_SECRET", "").strip() + + +def _le_segredo(caminho: str) -> Tuple[str, bool]: + """Devolve (segredo, veio_do_ambiente). Ambiente primeiro, depois o arquivo. + + É esta função, e nunca o nome público abaixo, que o resto do módulo chama. + No fim do arquivo cada painel liga as suas rotas ao módulo, e lá + `ler_segredo` pode ter outra assinatura -- a de quem ainda passa o + DIRETÓRIO em vez do caminho. Chamar daqui o nome público faria o núcleo + enxergar a versão de quem ligou por último. + """ + do_ambiente = segredo_do_ambiente() + if do_ambiente: + return do_ambiente, True + if caminho and os.path.exists(caminho): + try: + with open(caminho, "r", encoding="utf-8") as arquivo: + return arquivo.read().strip(), False + except OSError: + # Sem leitura equivale a "não há segredo": o SSO não liga, e o + # formulário local continua de pé. + return "", False + return "", False + + +def ler_segredo(caminho: str) -> Tuple[str, bool]: + """O mesmo que `_le_segredo`. É este o nome que as rotas chamam.""" + return _le_segredo(caminho) + + +def grava_segredo(caminho: str, valor: str) -> bool: + """Grava o segredo com modo 0600, como a credencial de recuperação.""" + if not caminho: + return False + try: + os.makedirs(os.path.dirname(caminho) or ".", exist_ok=True) + # 0600: só o dono do processo lê o segredo do cliente. + descritor = os.open(caminho, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descritor, "w", encoding="utf-8") as arquivo: + arquivo.write(valor) + return True + except OSError: + # Sem disco gravável o SSO NÃO liga. Ligar sem segredo seria desenhar + # na tela de login um botão que nunca abre. + return False + + +def apaga_segredo(caminho: str) -> None: + try: + if caminho and os.path.exists(caminho): + os.remove(caminho) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Configuração +# --------------------------------------------------------------------------- + +def lista_de(valor: str) -> Tuple[str, ...]: + """Divide uma lista separada por vírgula, em minúsculas e sem vazios. + + O `@` da frente cai: quem cadastra um domínio costuma escrever + `@empresa.com`, e uma entrada que nunca casa é pior que um erro -- ela liga + o SSO com uma lista que não autoriza ninguém e não diz por quê. + """ + return tuple( + parte.strip().lower().lstrip("@") for parte in (valor or "").split(",") + if parte.strip() + ) + + +@dataclass +class ConfiguracaoSSO: + """O que o painel sabe sobre o provedor. Nenhum segredo mora aqui.""" + + provedor: str = "" + base_url: str = "" + issuer: str = "" + client_id: str = "" + escopos: str = ESCOPOS_PADRAO + idp_entity_id: str = "" + idp_sso_url: str = "" + idp_cert: str = "" + dominios: Tuple[str, ...] = () + emails: Tuple[str, ...] = () + atualizado_em: str = "" + tem_segredo: bool = False + segredo_vem_do_ambiente: bool = False + desligado_no_ambiente: bool = False + + # -- estado ------------------------------------------------------------- + + def tem_allowlist(self) -> bool: + """Allowlist vazia significa que toda conta do provedor entraria.""" + return bool(self.dominios or self.emails) + + def esta_ligado(self) -> bool: + """Só liga com configuração COMPLETA. Meia configuração fica desligada.""" + if self.desligado_no_ambiente: + return False + if not self.base_url or not self.tem_allowlist(): + return False + if self.provedor == "oidc": + return bool(self.issuer and self.client_id and self.tem_segredo) + if self.provedor == "saml": + return bool( + self.idp_entity_id + and self.idp_sso_url + and self.idp_cert + and saml_disponivel() + ) + return False + + # -- endereços ---------------------------------------------------------- + # + # TODOS derivam de `base_url`, jamais do cabeçalho `Host`. Host é escolhido + # pelo cliente, e derivar dali a URL de retorno é a definição de + # redirect_uri aberto: o atacante decide para onde o `code` é entregue. + + def url_de_retorno(self) -> str: + return self.base_url.rstrip("/") + ROTA_OIDC_CALLBACK + + def url_do_acs(self) -> str: + return self.base_url.rstrip("/") + ROTA_SAML_ACS + + def entity_id(self) -> str: + return self.base_url.rstrip("/") + ROTA_SAML_METADATA + + def rota_de_entrada(self) -> str: + return ROTA_SAML_INICIAR if self.provedor == "saml" else ROTA_OIDC_INICIAR + + def nome_do_provedor(self) -> str: + """Como o botão da tela de login chama o provedor. + + É o host do issuer (ou do IdP), e não um rótulo digitado: assim o texto + do botão não pode discordar de para onde o clique leva. + """ + origem = self.issuer if self.provedor == "oidc" else ( + self.idp_sso_url or self.idp_entity_id + ) + alvo = urllib.parse.urlsplit(origem or "") + return alvo.hostname or (origem or "") + + +def carregar(prefs_path: str, caminho_do_segredo: str) -> ConfiguracaoSSO: + """Lê a configuração do banco de preferências e o estado do segredo.""" + from .prefs import get_preference + + def ler(chave: str, padrao: str = "") -> str: + return (get_preference(prefs_path, chave, padrao) or "").strip() + + segredo, do_ambiente = _le_segredo(caminho_do_segredo) + return ConfiguracaoSSO( + provedor=ler(CHAVE_PROVEDOR).lower(), + base_url=ler(CHAVE_BASE_URL), + issuer=ler(CHAVE_OIDC_ISSUER).rstrip("/"), + client_id=ler(CHAVE_OIDC_CLIENT_ID), + escopos=ler(CHAVE_OIDC_SCOPES) or ESCOPOS_PADRAO, + idp_entity_id=ler(CHAVE_SAML_IDP_ENTITY_ID), + idp_sso_url=ler(CHAVE_SAML_IDP_SSO_URL), + idp_cert=ler(CHAVE_SAML_IDP_CERT), + dominios=lista_de(ler(CHAVE_DOMINIOS)), + emails=lista_de(ler(CHAVE_EMAILS)), + atualizado_em=ler(CHAVE_ATUALIZADO_EM), + tem_segredo=bool(segredo), + segredo_vem_do_ambiente=do_ambiente, + desligado_no_ambiente=desligado_por_ambiente(), + ) + + +def gravar(prefs_path: str, campos: Dict[str, str]) -> bool: + """Grava as chaves `sso.*` informadas. Devolve False se o disco recusar.""" + from .prefs import set_preference + + tudo_certo = True + for chave, valor in campos.items(): + if not set_preference(prefs_path, chave, valor): + tudo_certo = False + momento = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + set_preference(prefs_path, CHAVE_ATUALIZADO_EM, momento) + return tudo_certo + + +def email_autorizado(email: str, config: ConfiguracaoSSO) -> bool: + """Allowlist OBRIGATÓRIA: e-mail exato, ou domínio inteiro. + + Sem ela, "entrar com Google" significa que toda conta Google do planeta + entra no painel. Por isso a lista vazia não é "sem filtro": é o painel + recusando ligar o SSO. + """ + alvo = (email or "").strip().lower() + if not alvo or "@" not in alvo or not config.tem_allowlist(): + return False + if alvo in config.emails: + return True + return alvo.rsplit("@", 1)[1] in config.dominios + + +# --------------------------------------------------------------------------- +# Transporte +# --------------------------------------------------------------------------- + +def _transporte_seguro(url: str) -> bool: + """HTTPS sempre, com a única exceção do loopback. + + O contexto TLS é o padrão da biblioteca, que VERIFICA o certificado. A + exceção do loopback existe porque um provedor de teste em `127.0.0.1` nunca + põe um byte na rede — e é assim que a suíte exercita o fluxo inteiro sem + depender da internet. + """ + partes = urllib.parse.urlsplit(url or "") + if partes.scheme == "https": + return True + return partes.scheme == "http" and partes.hostname in HOSTS_DE_LOOPBACK + + +def _pedir(url: str, dados: Optional[bytes] = None, + cabecalhos: Optional[Dict[str, str]] = None, + timeout: float = TIMEOUT_TOKEN) -> dict: + """Uma chamada HTTP que devolve JSON, ou levanta FalhaDeSSO.""" + if not _transporte_seguro(url): + raise FalhaDeSSO(f"endereco sem TLS fora do loopback: {url}") + pedido = urllib.request.Request(url, data=dados, headers=cabecalhos or {}) + try: + with urllib.request.urlopen(pedido, timeout=timeout) as resposta: + corpo = resposta.read() + except (urllib.error.URLError, OSError, ValueError) as erro: + raise FalhaDeSSO(f"falha ao falar com o provedor: {erro}") from erro + try: + return json.loads(corpo.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as erro: + raise FalhaDeSSO("o provedor respondeu algo que não é JSON") from erro + + +def descobrir(issuer: str, agora: Optional[float] = None) -> dict: + """Documento de descoberta do issuer, com cache de uma hora por processo. + + Levanta FalhaDeSSO por qualquer motivo -- inclusive por um que acabou de + acontecer: a falha fica memorizada por um minuto, e dentro dessa janela a + resposta sai daqui sem pôr um byte na rede. + """ + issuer = (issuer or "").rstrip("/") + if not issuer: + raise FalhaDeSSO("issuer não configurado") + if not _transporte_seguro(issuer): + raise FalhaDeSSO("issuer sem TLS fora do loopback") + agora = agora if agora is not None else time.time() + with _trava: + guardado = _descobertas.get(issuer) + if guardado and guardado[0] > agora: + if guardado[1] is None: + raise FalhaDeSSO("a descoberta falhou há pouco e ainda está memorizada") + return guardado[1] + + try: + documento = _pedir( + issuer + "/.well-known/openid-configuration", timeout=TIMEOUT_DESCOBERTA + ) + + # Defesa contra mix-up: o documento tem de se declarar do MESMO issuer + # que está configurado aqui. Sem esta conferência, um provedor hostil + # que responda pelo endereço configurado pode se apresentar como outro. + if str(documento.get("issuer", "")).rstrip("/") != issuer: + raise FalhaDeSSO("o documento de descoberta declara outro issuer") + for campo in ("authorization_endpoint", "token_endpoint", "userinfo_endpoint"): + alvo = str(documento.get(campo) or "") + if not alvo: + raise FalhaDeSSO(f"o documento de descoberta não traz {campo}") + # O endereço é recusado AQUI, e não só na hora de usá-lo: um + # documento que aponta o token_endpoint para `http://` não vale nada + # inteiro, e guardá-lo seria repetir a recusa a cada passo. + if not _transporte_seguro(alvo): + raise FalhaDeSSO(f"{campo} do documento de descoberta está sem TLS") + except Exception as erro: + _log.warning("SSO: a descoberta do emissor falhou (%s)", erro) + with _trava: + _descobertas[issuer] = (agora + VALIDADE_DA_FALHA_DE_DESCOBERTA, None) + if isinstance(erro, FalhaDeSSO): + raise + raise FalhaDeSSO(f"descoberta falhou: {type(erro).__name__}") from erro + + with _trava: + _descobertas[issuer] = (agora + VALIDADE_DA_DESCOBERTA, documento) + return documento + + +def esquece_descobertas() -> None: + """Zera o cache. Existe para a suíte, e para um reload futuro.""" + with _trava: + _descobertas.clear() + + +# --------------------------------------------------------------------------- +# OIDC +# --------------------------------------------------------------------------- + +def novo_segredo_de_fluxo() -> str: + return secrets.token_urlsafe(32) + + +def novo_verificador() -> str: + return secrets.token_urlsafe(64) + + +def desafio_de(verificador: str) -> str: + """PKCE S256, base64url sem preenchimento. + + **Sempre** S256, nunca "plain": o painel roda em HTTP no loopback, o `code` + passa pela barra de endereços e fica no histórico. State e nonce sozinhos + não protegem contra um code interceptado, e a decisão não muda com o + ambiente -- atrás de um túnel HTTPS o risco cai, mas o custo de S256 é zero. + """ + resumo = hashlib.sha256((verificador or "").encode("ascii")).digest() + return base64.urlsafe_b64encode(resumo).decode("ascii").rstrip("=") + + +def parametros_de_autorizacao(config: ConfiguracaoSSO, state: str, nonce: str, + desafio: str) -> Dict[str, str]: + """Os campos da ida, separados de como eles viram endereço. + + Quem já calculou o desafio PKCE monta a URL com estes campos sem refazer a + conta, e a lista de campos continua existindo num lugar só. + """ + return { + "response_type": "code", + "client_id": config.client_id, + "redirect_uri": config.url_de_retorno(), + "scope": config.escopos or ESCOPOS_PADRAO, + "state": state, + "nonce": nonce, + "code_challenge": desafio, + "code_challenge_method": "S256", + # Escolher a conta explicitamente: sem isto, quem já está logado no + # provedor com OUTRA conta entra com ela sem ver qual é. + "prompt": "select_account", + } + + +def url_de_autorizacao(documento: dict, config: ConfiguracaoSSO, + state: str, nonce: str, verificador: str) -> str: + """Endereço para onde o navegador é enviado, já com PKCE.""" + destino = str(documento.get("authorization_endpoint") or "") + juncao = "&" if "?" in destino else "?" + return destino + juncao + urllib.parse.urlencode( + parametros_de_autorizacao(config, state, nonce, desafio_de(verificador)) + ) + + +def troca_o_code(documento: dict, config: ConfiguracaoSSO, segredo: str, + code: str, verificador: str) -> dict: + """Troca o `code` pelos tokens, autenticando o cliente por client_secret_basic.""" + corpo = urllib.parse.urlencode({ + "grant_type": "authorization_code", + "code": code, + # O MESMO endereço enviado na ida, e sempre o da configuração. + "redirect_uri": config.url_de_retorno(), + "code_verifier": verificador, + }).encode("utf-8") + credencial = "{}:{}".format( + urllib.parse.quote(config.client_id, safe=""), + urllib.parse.quote(segredo, safe=""), + ) + cabecalhos = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "Authorization": "Basic " + base64.b64encode(credencial.encode("utf-8")).decode("ascii"), + } + resposta = _pedir(str(documento.get("token_endpoint") or ""), corpo, cabecalhos) + if resposta.get("error"): + raise FalhaDeSSO("o provedor recusou a troca do code") + if not resposta.get("id_token") or not resposta.get("access_token"): + raise FalhaDeSSO("a troca do code não devolveu os dois tokens") + return resposta + + +def decodifica_payload(id_token: str) -> dict: + """Lê o corpo do `id_token` sem verificar a assinatura -- ver o topo do módulo.""" + partes = (id_token or "").split(".") + if len(partes) != 3: + raise FalhaDeSSO("id_token malformado") + corpo = partes[1] + # base64url do JWT vem sem preenchimento; sem isto a decodificação levanta. + corpo += "=" * (-len(corpo) % 4) + try: + return json.loads(base64.urlsafe_b64decode(corpo.encode("ascii")).decode("utf-8")) + except (ValueError, UnicodeDecodeError, TypeError) as erro: + raise FalhaDeSSO("id_token com corpo ilegível") from erro + + +def mesmo_texto(a: str, b: str) -> bool: + """Comparação em tempo constante que aceita qualquer texto. + + `hmac.compare_digest` levanta TypeError com `str` fora de ASCII, e o `state` + da query é escolhido por quem chama -- comparar os bytes evita transformar + um valor hostil em erro 500. + """ + return hmac.compare_digest(str(a or "").encode("utf-8"), str(b or "").encode("utf-8")) + + +def confere_id_token(payload: dict, config: ConfiguracaoSSO, nonce: str, + agora: Optional[float] = None) -> None: + """Lista fechada de conferências. Levanta FalhaDeSSO na primeira que falhar.""" + agora = agora if agora is not None else time.time() + + if str(payload.get("iss", "")).rstrip("/") != config.issuer: + raise FalhaDeSSO("iss do id_token difere do issuer configurado") + + audiencia = payload.get("aud") + audiencias = audiencia if isinstance(audiencia, list) else [audiencia] + if config.client_id not in [str(item) for item in audiencias if item is not None]: + raise FalhaDeSSO("aud do id_token não contém o client_id") + if len(audiencias) > 1 and str(payload.get("azp", "")) != config.client_id: + raise FalhaDeSSO("id_token com várias audiências e azp que não é nosso") + + try: + expira = float(payload.get("exp")) + emitido = float(payload.get("iat")) + except (TypeError, ValueError) as erro: + raise FalhaDeSSO("id_token sem exp/iat utilizáveis") from erro + if expira <= agora: + raise FalhaDeSSO("id_token expirado") + if abs(agora - emitido) > TOLERANCIA_DE_RELOGIO: + raise FalhaDeSSO("iat fora da tolerância: confira o relógio do container") + + if not mesmo_texto(str(payload.get("nonce", "")), nonce): + raise FalhaDeSSO("nonce do id_token difere do que saiu daqui") + + +_estados_consumidos: Dict[str, float] = {} + + +def estado_ja_usado(state: str, agora: Optional[float] = None) -> bool: + """Uso único do estado da ida. Devolve True se ele JÁ tinha sido gasto. + + Apagar o cookie na volta não basta: quem guardou o valor pode reapresentá-lo. + O `code` também morre na primeira troca -- mas isso depende do provedor, e o + controle de quem entra neste painel tem de ser nosso. + """ + agora = agora if agora is not None else time.time() + with _trava: + _limpa_o_que_envelheceu(agora) + if state in _estados_consumidos: + return True + _estados_consumidos[state] = agora + return False + + +def busca_userinfo(documento: dict, access_token: str) -> dict: + """Ancora o `sub`: o userinfo só responde a quem tem o access_token.""" + return _pedir( + str(documento.get("userinfo_endpoint") or ""), + cabecalhos={"Authorization": "Bearer " + str(access_token or ""), + "Accept": "application/json"}, + ) + + +def email_do_userinfo(userinfo: dict, payload: dict) -> str: + """Confere o vínculo e devolve o e-mail verificado, ou levanta.""" + # OIDC Core 5.3.2: o `sub` do userinfo TEM de ser o mesmo do id_token. + if not mesmo_texto(str(userinfo.get("sub", "")), str(payload.get("sub", ""))): + raise FalhaDeSSO("sub do userinfo difere do sub do id_token") + verificado = userinfo.get("email_verified") + if verificado is None: + verificado = payload.get("email_verified") + # Sem esta conferência, qualquer conta com e-mail não confirmado no provedor + # passaria pela allowlist de domínio. + if verificado is not True and str(verificado).lower() != "true": + raise FalhaDeSSO("o provedor não afirma que o e-mail foi verificado") + email = str(userinfo.get("email") or payload.get("email") or "").strip().lower() + if not email: + raise FalhaDeSSO("o provedor não devolveu e-mail") + return email + + +# --------------------------------------------------------------------------- +# SAML2 -- o que é NOSSO +# --------------------------------------------------------------------------- +# +# A validação da assinatura XML é da biblioteca (`python3-saml`), importada de +# forma preguiçosa. O que não é dela, e por isso está aqui: o conjunto de +# requisições pendentes (que é o que dá sentido ao `InResponseTo`) e o cache de +# repetição do ID da asserção -- a biblioteca NÃO guarda IDs já consumidos. + +_saml_disponivel: Optional[bool] = None + + +def saml_disponivel() -> bool: + """A biblioteca de SAML está instalada nesta imagem? + + Import preguiçoso e resultado guardado: quem só quer OIDC não carrega nada, + e a aba SAML da tela aparece desabilitada com a mensagem traduzida em vez de + o painel quebrar. + """ + global _saml_disponivel + if _saml_disponivel is None: + try: + import onelogin.saml2.auth # noqa: F401 + _saml_disponivel = True + except Exception: + _saml_disponivel = False + return _saml_disponivel + + +def _limpa_o_que_envelheceu(agora: float) -> None: + """Descarta o que saiu da janela, para a memória não crescer sem limite.""" + for guardado in (_pendentes, _assercoes_consumidas, _estados_consumidos): + for identificador in list(guardado): + if agora - guardado[identificador] > VALIDADE_DO_PENDENTE: + guardado.pop(identificador, None) + + +def novo_id_de_requisicao() -> str: + """ID de AuthnRequest. Começa com "_" porque xs:ID não aceita dígito inicial.""" + return "_" + secrets.token_hex(16) + + +def registra_pendente(identificador: str, agora: Optional[float] = None) -> None: + """Anota uma AuthnRequest à espera de resposta, com teto duro de memória.""" + agora = agora if agora is not None else time.time() + with _trava: + _limpa_o_que_envelheceu(agora) + if len(_pendentes) >= LIMITE_DE_PENDENTES: + # Descarta o mais antigo: a rota é pública, e sem teto ela vira + # consumo de memória ilimitado. + mais_antigo = min(_pendentes, key=_pendentes.get) + _pendentes.pop(mais_antigo, None) + _pendentes[identificador] = agora + + +def consome_pendente(identificador: str, agora: Optional[float] = None) -> bool: + """Uso único: a mesma resposta não vale duas vezes.""" + agora = agora if agora is not None else time.time() + if not identificador: + return False + with _trava: + _limpa_o_que_envelheceu(agora) + return _pendentes.pop(identificador, None) is not None + + +def assercao_ja_usada(identificador: str, agora: Optional[float] = None) -> bool: + """Cache de repetição: dentro da janela de validade, um ID vale uma vez só.""" + agora = agora if agora is not None else time.time() + with _trava: + _limpa_o_que_envelheceu(agora) + if identificador in _assercoes_consumidas: + return True + _assercoes_consumidas[identificador] = agora + return False + + +def esquece_estado_de_fluxo() -> None: + """Zera pendentes, repetições e estados gastos. Existe para a suíte.""" + with _trava: + _pendentes.clear() + _assercoes_consumidas.clear() + _estados_consumidos.clear() + + +# InResponseTo do elemento raiz. Serve APENAS para achar qual AuthnRequest nossa +# está sendo respondida; quem confere o valor de verdade é a biblioteca, contra +# o `request_id` que passamos, e sobre conteúdo assinado. +_RX_IN_RESPONSE_TO = re.compile(r'InResponseTo="([^"]+)"') + + +def in_response_to(saml_response: str) -> str: + """Lê o InResponseTo da resposta. Resposta sem ele é RECUSADA. + + Uma resposta sem `InResponseTo` é o fluxo iniciado pelo IdP, e esse é o + vetor clássico de CSRF de login em SAML: o atacante entrega ao navegador da + vítima uma asserção legítima da conta DELE. + """ + try: + cru = base64.b64decode(saml_response or "", validate=False) + except Exception as erro: + raise FalhaDeSSO("SAMLResponse não é base64") from erro + achado = _RX_IN_RESPONSE_TO.search(cru.decode("utf-8", "replace")) + if not achado: + raise FalhaDeSSO("resposta sem InResponseTo (iniciada pelo IdP) é recusada") + return achado.group(1) + + +def _atributo(valor: str) -> str: + """Escapa um valor para caber num atributo XML.""" + return ( + str(valor or "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def monta_authn_request(config: ConfiguracaoSSO, identificador: str, + agora: Optional[float] = None) -> str: + """AuthnRequest mínima, NÃO assinada. + + Assinar exigiria uma chave privada do SP -- mais um segredo, mais um arquivo + 0600 -- e quase nenhum IdP a exige. Se o do cliente exigir, é a próxima + versão, com a chave seguindo a mesma regra do segredo OIDC. + """ + agora = agora if agora is not None else time.time() + instante = datetime.fromtimestamp(agora, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return ( + '' + f'{_atributo(config.entity_id())}' + '' + ) + + +def url_de_ida_saml(config: ConfiguracaoSSO, xml: str) -> str: + """HTTP-Redirect binding: deflate cru, base64 e querystring. + + Não é HTTP-POST binding porque um `
    ` bateria + na CSP do painel, que declara `form-action 'self'` -- o navegador bloqueia a + submissão e nada na tela explica por quê. + """ + compressor = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS) + cru = compressor.compress(xml.encode("utf-8")) + compressor.flush() + parametro = base64.b64encode(cru).decode("ascii") + juncao = "&" if "?" in config.idp_sso_url else "?" + return config.idp_sso_url + juncao + urllib.parse.urlencode({"SAMLRequest": parametro}) + + +def configuracao_da_biblioteca(config: ConfiguracaoSSO) -> dict: + """Settings do `python3-saml`, com os defaults perigosos sobrescritos. + + Por padrão a biblioteca monta a URL do ACS a partir de `http_host`/`https` + da requisição e compara o `Destination` com ela -- exatamente a porta que + fechamos no OIDC. Aqui tudo sai de `sso.base_url`. + """ + return { + "strict": True, + "debug": False, + "sp": { + "entityId": config.entity_id(), + "assertionConsumerService": { + "url": config.url_do_acs(), + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", + }, + "idp": { + "entityId": config.idp_entity_id, + "singleSignOnService": { + "url": config.idp_sso_url, + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + }, + "x509cert": config.idp_cert, + }, + "security": { + "wantAssertionsSigned": True, + "wantMessagesSigned": True, + "rejectUnsolicitedResponsesWithInResponseTo": True, + "wantNameId": True, + # O painel identifica pelo NameID no formato de e-mail, e muitos + # provedores mandam só isso. O default da biblioteca é exigir um + # bloco de atributos, o que recusaria asserções perfeitamente + # válidas -- e a tentação seguinte seria relaxar algo que importa. + "wantAttributeStatement": False, + "authnRequestsSigned": False, + "wantAssertionsEncrypted": False, + "requestedAuthnContext": False, + }, + } + + +def dados_da_requisicao(config: ConfiguracaoSSO, post: Dict[str, str]) -> dict: + """O dicionário que a biblioteca lê no lugar da requisição. + + Montado por NÓS a partir de `sso.base_url`: nenhum cabeçalho da requisição + entra aqui, que é o que impede o cliente de escolher o `Destination` aceito. + """ + partes = urllib.parse.urlsplit(config.base_url) + # A porta viaja DENTRO do `http_host`, e não num campo próprio. O campo + # separado está obsoleto na biblioteca, e preenchê-lo com vazio -- o que + # acontece quando a `base_url` não declara porta -- fazia a biblioteca + # montar "https://painel.exemplo.com:/sso/saml/acs", com dois-pontos e sem + # número, e recusar a própria asserção correta dizendo que o destino não + # batia. Encontrado na bancada, com asserção assinada de verdade. + return { + "https": "on" if partes.scheme == "https" else "off", + "http_host": partes.netloc, + "script_name": urllib.parse.urlsplit(config.url_do_acs()).path, + "get_data": {}, + "post_data": dict(post or {}), + } + + +def processa_resposta_saml(config: ConfiguracaoSSO, saml_response: str, + id_pendente: str, agora: Optional[float] = None) -> str: + """Valida a resposta do IdP e devolve o e-mail. Levanta FalhaDeSSO se algo falhar. + + A assinatura, o `Audience`, as janelas de tempo, o `Destination` e o + `InResponseTo` são conferidos pela biblioteca -- é para isso que ela existe, + e é por isso que ela é dependência: defender-se de XML Signature Wrapping + exige amarrar a referência da assinatura ao elemento efetivamente lido, o + que não se faz com regex nem com `xml.etree`. + """ + if not saml_disponivel(): + raise FalhaDeSSO("SAML2 não está disponível nesta imagem") + from onelogin.saml2.auth import OneLogin_Saml2_Auth # import preguiçoso + + autenticacao = OneLogin_Saml2_Auth( + dados_da_requisicao(config, {"SAMLResponse": saml_response}), + configuracao_da_biblioteca(config), + ) + # `request_id` é o que liga o NOSSO conjunto de pendentes à validação da + # biblioteca: sem ele, `InResponseTo` não é conferido contra nada. + autenticacao.process_response(request_id=id_pendente) + erros = autenticacao.get_errors() + if erros or not autenticacao.is_authenticated(): + # O motivo detalhado da biblioteca vai junto: ele nomeia QUAL condição + # falhou (prazo, audiência, destino, assinatura) e é o que torna o log + # interno útil. Não carrega credencial -- a asserção em si nunca entra. + raise FalhaDeSSO( + "a biblioteca recusou a asserção: " + + ", ".join(erros) + + " (" + str(autenticacao.get_last_error_reason() or "sem detalhe") + ")" + ) + + # Repetição dentro da janela de validade: a biblioteca NÃO guarda IDs já + # consumidos, então este controle é nosso. + identificador = autenticacao.get_last_assertion_id() + if identificador and assercao_ja_usada(identificador, agora): + raise FalhaDeSSO("asserção repetida dentro da janela de validade") + + email = str(autenticacao.get_nameid() or "").strip().lower() + if "@" not in email: + atributos = autenticacao.get_attributes() or {} + for nome in ("email", "mail", "urn:oid:0.9.2342.19200300.100.1.3", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"): + valores = atributos.get(nome) or [] + if valores: + email = str(valores[0]).strip().lower() + break + if "@" not in email: + raise FalhaDeSSO("a asserção não traz e-mail") + return email + + +def metadata_do_sp(config: ConfiguracaoSSO) -> str: + """XML de metadados que o operador entrega ao IdP. + + Servido apenas COM sessão: não há pressa nenhuma em publicá-lo sem login, e + cada rota pública a mais é superfície a mais. + """ + if not saml_disponivel(): + raise FalhaDeSSO("SAML2 não está disponível nesta imagem") + from onelogin.saml2.settings import OneLogin_Saml2_Settings # import preguiçoso + + ajustes = OneLogin_Saml2_Settings(configuracao_da_biblioteca(config), sp_validation_only=True) + metadados = ajustes.get_sp_metadata() + # A biblioteca devolve `bytes` quando o SP tem certificado e `str` quando + # não tem -- e o nosso não tem, porque a AuthnRequest não é assinada nesta + # versão. Tratar os dois casos evita um AttributeError subindo até o + # handler, onde nada o capturaria. + if isinstance(metadados, bytes): + return metadados.decode("utf-8") + return str(metadados) + + +# --------------------------------------------------------------------------- +# Ligação com as rotas deste painel +# --------------------------------------------------------------------------- +# +# O núcleo acima é o MESMO texto nos três irmãos. Daqui para baixo estão os +# nomes que o `web.py` deste painel chama hoje, escritos por cima dele: não há +# regra nova nesta seção, só tradução de assinatura -- dicionário no lugar da +# `ConfiguracaoSSO`, e a forma que cada rota aprendeu a chamar. Quando `web.py` +# passar a chamar os nomes do núcleo, esta seção some inteira e os três +# arquivos ficam iguais byte a byte, que é o objetivo. + +# `grava_segredo` é redefinido aqui embaixo com a assinatura que as rotas usam. +# A versão do núcleo fica guardada ANTES da troca -- sem isto, a de baixo +# chamaria a si mesma. +_grava_segredo_no_arquivo = grava_segredo + +# O que a tela aceita gravar. O SAML2 está implementado no núcleo, mas as rotas +# `/sso/saml/*` ainda não existem no `web.py` deste painel: oferecer o provedor +# na tela desenharia um botão para uma rota que responde 404. +PROVEDORES = ("", "oidc") + +# O caminho da volta, no nome que `web.py` usa para montar a URL que o operador +# cadastra no provedor. +ROTA_CALLBACK = ROTA_OIDC_CALLBACK + + +def caminho_do_segredo(base_dir: str) -> str: + """O arquivo do segredo, ao lado da credencial de recuperação.""" + return os.path.join(base_dir or ".", ARQUIVO_DO_SEGREDO) + + +def segredo_vem_do_ambiente() -> bool: + """A tela trava o campo quando o ambiente manda, como já faz com a senha.""" + return bool(segredo_do_ambiente()) + + +def ler_segredo(base_dir: str) -> str: + """O segredo em vigor, ou vazio. Recebe o DIRETÓRIO, não o arquivo.""" + return _le_segredo(caminho_do_segredo(base_dir))[0] + + +def grava_segredo(base_dir: str, valor: str) -> bool: + """Grava o segredo 0600 no diretório dado. False quando o disco recusa.""" + return _grava_segredo_no_arquivo(caminho_do_segredo(base_dir), str(valor or "").strip()) + + +def normaliza_base_url(url: str) -> str: + return str(url or "").strip().rstrip("/") + + +def url_segura(url: str) -> bool: + """Aceita `https` em qualquer lugar e `http` apenas no loopback.""" + return _transporte_seguro(url) + + +def base_url_valida(url: str) -> bool: + """Origem pública EXATA: esquema e host, sem caminho, sem consulta. + + Fora do loopback só `https`. O redirecionamento registrado no provedor sai + daqui, nunca do cabeçalho `Host` -- derivá-lo do `Host`, que é escolhido + pelo cliente, é a definição de redirecionamento aberto. + """ + if not url: + return False + partes = urllib.parse.urlsplit(str(url).strip()) + if partes.scheme not in ("http", "https"): + return False + if not partes.netloc: + return False + if partes.path not in ("", "/") or partes.query or partes.fragment: + return False + return url_segura(url) + + +def redirect_uri(config: Dict[str, object]) -> str: + """SEMPRE da configuração, JAMAIS do cabeçalho `Host`. + + `Host` é escolhido pelo cliente. Derivar dali a URL de retorno é a + definição de redirect_uri aberto: o atacante manda o código de autorização + para onde quiser. + """ + return str(config.get("base_url") or "").rstrip("/") + ROTA_OIDC_CALLBACK + + +def nome_do_provedor(config: Optional[Dict[str, object]]) -> str: + """Como o botão da tela de login chama o provedor: o host do emissor. + + Inventar um nome amigável exigiria uma tabela de provedores conhecidos que + envelheceria calada; o host é o que o operador cadastrou e reconhece. As + duas grafias da chave -- `issuer` e `oidc_issuer` -- são aceitas enquanto + os painéis não convergem no nome. + """ + origem = str((config or {}).get("issuer") or (config or {}).get("oidc_issuer") or "") + return urllib.parse.urlsplit(origem).hostname or origem + + +def ler_configuracao(prefs_path: str) -> Dict[str, str]: + """Tudo o que a tela precisa mostrar. NUNCA inclui o segredo do cliente.""" + from .prefs import get_preference + + def ler(chave: str) -> str: + return (get_preference(prefs_path, chave, "") or "").strip() + + return { + "enabled": ler(CHAVE_PROVEDOR), + "base_url": ler(CHAVE_BASE_URL), + "issuer": ler(CHAVE_OIDC_ISSUER), + "client_id": ler(CHAVE_OIDC_CLIENT_ID), + "scopes": ler(CHAVE_OIDC_SCOPES) or ESCOPOS_PADRAO, + "allowed_domains": ler(CHAVE_DOMINIOS), + "allowed_emails": ler(CHAVE_EMAILS), + "updated_at": ler(CHAVE_ATUALIZADO_EM), + } + + +def grava_configuracao(prefs_path: str, campos: Dict[str, str]) -> bool: + """Grava a configuração não sensível. O segredo tem caminho próprio.""" + return gravar(prefs_path, { + CHAVE_PROVEDOR: campos.get("enabled", ""), + CHAVE_BASE_URL: normaliza_base_url(campos.get("base_url", "")), + CHAVE_OIDC_ISSUER: normaliza_base_url(campos.get("issuer", "")), + CHAVE_OIDC_CLIENT_ID: campos.get("client_id", ""), + CHAVE_OIDC_SCOPES: campos.get("scopes", "") or ESCOPOS_PADRAO, + CHAVE_DOMINIOS: campos.get("allowed_domains", ""), + CHAVE_EMAILS: campos.get("allowed_emails", ""), + }) + + +def problemas_da_configuracao(campos: Dict[str, str], tem_segredo: bool) -> Tuple[str, ...]: + """Chaves de tradução do que impede LIGAR o SSO. Vazio é aceite. + + A lista de permissão é obrigatória e não pode ser vazia: "entrar com o + provedor X" sem filtro significa que toda conta do provedor X entra aqui. + """ + problemas = [] + if not base_url_valida(campos.get("base_url", "")): + problemas.append("sso.need_base_url") + if not str(campos.get("issuer", "")).strip() or not url_segura(campos.get("issuer", "")): + problemas.append("sso.need_issuer") + if not str(campos.get("client_id", "")).strip(): + problemas.append("sso.need_client_id") + if not tem_segredo: + problemas.append("sso.need_secret") + if not lista_de(campos.get("allowed_domains", "")) and not lista_de( + campos.get("allowed_emails", "") + ): + problemas.append("sso.need_allowlist") + return tuple(problemas) + + +def configuracao_efetiva(prefs_path: str, base_dir: str) -> Optional[Dict[str, object]]: + """A configuração que o fluxo usa, ou None quando o SSO não deve funcionar. + + Devolver None é o estado "sem configuração, nada muda": a tela de login não + desenha o botão e as rotas `/sso/*` recusam. + """ + if desligado_por_ambiente(): + return None + + campos = ler_configuracao(prefs_path) + if campos["enabled"] != "oidc": + return None + + segredo = ler_segredo(base_dir) + if problemas_da_configuracao(campos, bool(segredo)): + return None + + return { + "base_url": normaliza_base_url(campos["base_url"]), + "issuer": normaliza_base_url(campos["issuer"]), + "client_id": campos["client_id"], + "client_secret": segredo, + "scopes": campos["scopes"] or ESCOPOS_PADRAO, + "allowed_domains": campos["allowed_domains"], + "allowed_emails": campos["allowed_emails"], + } + + +def _configuracao_de(config: Dict[str, object]) -> ConfiguracaoSSO: + """O dicionário que as rotas carregam, no formato do núcleo. + + As duas grafias de chave convivem aqui pelo mesmo motivo do resto da seção: + `issuer` e `oidc_issuer` são o mesmo campo com nome diferente, e é esta + camada que absorve isso enquanto `web.py` não converge. + """ + def campo(*nomes: str) -> str: + for nome in nomes: + valor = str(config.get(nome) or "").strip() + if valor: + return valor + return "" + + return ConfiguracaoSSO( + provedor="oidc", + base_url=campo("base_url").rstrip("/"), + issuer=campo("issuer", "oidc_issuer").rstrip("/"), + client_id=campo("client_id", "oidc_client_id"), + escopos=campo("scopes", "oidc_scopes") or ESCOPOS_PADRAO, + dominios=lista_de(campo("allowed_domains")), + emails=lista_de(campo("allowed_emails")), + tem_segredo=bool(config.get("client_secret")), + ) + + +def limpa_cache_descoberta() -> None: + """Zera o documento memorizado. O emissor pode ter mudado na tela.""" + esquece_descobertas() + + +def descobre(issuer: str, agora: Optional[float] = None) -> Optional[Dict[str, object]]: + """O documento do emissor, ou None quando ele não responde. + + None em vez de exceção porque quem pergunta é a tela de login: provedor + fora do ar faz o botão SUMIR, e o formulário local continua de pé. + """ + try: + return descobrir(issuer, agora=agora) + except FalhaDeSSO: + return None + + +def url_de_autorizacao(config: Dict[str, object], documento: Dict[str, object], + state: str, nonce: str, verificador: str) -> str: + """Para onde mandamos o navegador. O `redirect_uri` sai da configuração.""" + destino = str(documento["authorization_endpoint"]) + juncao = "&" if "?" in destino else "?" + return destino + juncao + urllib.parse.urlencode( + parametros_de_autorizacao( + _configuracao_de(config), state, nonce, desafio_de(verificador) + ) + ) + + +def conclui_login(*, config: Dict[str, object], estado: Optional[Dict[str, str]], + parametros: Dict[str, str], + agora: Optional[float] = None) -> Tuple[str, str]: + """Valida a volta do provedor. Devolve (email, motivo_da_recusa). + + E-mail vazio significa recusa. O motivo é para o log interno: na tela, toda + falha é a MESMA frase -- distinguir "state errado" de "e-mail fora da + lista" conta ao atacante em que ponto do fluxo ele está. + + A ordem é a da especificação, e para na primeira falha. + """ + alvo = _configuracao_de(config) + try: + # 1. Cookie de estado íntegro. Sem ele não há nada com que comparar o + # state, e aceitar assim mesmo é o pedido forjado de login. + if not estado or not estado.get("state"): + raise FalhaDeSSO("cookie de estado ausente ou corrompido") + # 2. O state da volta bate com o do cookie, e vale uma vez só: apagar o + # cookie não basta, porque quem guardou o valor o reapresenta. + if not mesmo_texto(str(parametros.get("state") or ""), str(estado["state"])): + raise FalhaDeSSO("state diferente do gravado no cookie") + if estado_ja_usado(str(estado["state"]), agora): + raise FalhaDeSSO("state já gasto: volta repetida") + # 3. O provedor pode ter recusado antes de chegar aqui. + if parametros.get("error"): + raise FalhaDeSSO("o provedor devolveu erro na autorização") + codigo = str(parametros.get("code") or "").strip() + if not codigo: + raise FalhaDeSSO("code ausente na volta do provedor") + + documento = descobrir(alvo.issuer, agora=agora) + # 4. Troca do código pelo token, pelo canal direto e autenticado. + tokens = troca_o_code( + documento, alvo, str(config.get("client_secret") or ""), codigo, + str(estado.get("verificador") or ""), + ) + payload = decodifica_payload(tokens["id_token"]) + confere_id_token(payload, alvo, str(estado.get("nonce") or ""), agora=agora) + # 5. O userinfo prova que o access_token é real e ancora o `sub`. + email = email_do_userinfo(busca_userinfo(documento, tokens["access_token"]), payload) + # 6. Lista de permissão, obrigatória e não vazia. + if not email_autorizado(email, alvo): + raise FalhaDeSSO("e-mail fora da lista de permissão") + except FalhaDeSSO as erro: + return "", erro.detalhe + return email, "" diff --git a/src/nine_rtksync/web.py b/src/nine_rtksync/web.py new file mode 100644 index 0000000..4905d1f --- /dev/null +++ b/src/nine_rtksync/web.py @@ -0,0 +1,1414 @@ +"""Painel deste sincronizador, renderizado inteiramente no servidor. + +Este módulo cuida do transporte — rotas, autenticação, cabeçalhos e ações. Todo +o HTML vive em `render.py` e tudo o que sabe de QUAL gateway se trata vive em +`gateway.py`: foi a mistura dessas três coisas num arquivo só que fez os painéis +irmãos divergirem sem que ninguém percebesse. + +Mesma postura nos três, pelas mesmas razões: + +- nada de JavaScript buscando dado: a página chega pronta, então não existe + endpoint público servindo estado do gateway; +- cabeçalhos de segurança em **toda** resposta, inclusive no corpo do 401 — que + é o que o navegador mostra quando se aperta ESC no diálogo do Basic Auth; +- nenhuma credencial aparece em corpo de resposta, log ou banner; +- POST de outra origem é recusado, porque o navegador anexa o Basic Auth + sozinho num formulário de terceiro; +- rota que este servidor não serve responde 404 ANTES de qualquer exigência de + sessão: "você precisa entrar" e "isso não existe" são respostas diferentes + para perguntas diferentes. +""" + +import base64 +import json +import os +import re +import secrets +import sys +import threading +import time +import urllib.error +import urllib.request +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Callable, Dict, List, Optional +from urllib.parse import parse_qs, urlencode, urlparse + +from .config import Settings +from .i18n import DEFAULT_LANGUAGE, normalize_language, translate +from .identidade import NOME_DO_GATEWAY, NOME_DO_PRODUTO +from .logs import get_logger +from .prefs import get_preference, set_preference +from . import protecao, sessao, sso +from .render import render_dashboard, render_login_page, render_notice_page +from .gateway import CATALOGO_INACESSIVEL, carregar_painel, esquece_o_catalogo, sondar +from .render import render_landing_page + +# Tempo de vida do resultado da sondagem ao gateway. O /healthz é chamado a cada +# 15s pelo Docker; sem cache, cada chamada faria uma requisição HTTP de saída de +# até 3s, atrasando a resposta além do timeout do probe. +GATEWAY_PROBE_TTL_SECONDS = 30.0 + +# Erros de socket que significam apenas "o cliente desistiu antes de ler a +# resposta" — comportamento normal de health check, não falha do servidor. +CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) + +# Cache da sondagem ao gateway, compartilhado entre as threads do servidor. +_gateway_probe_cache: Dict[str, tuple] = {} +_gateway_probe_lock = threading.Lock() + + +def strip_markup(text: str) -> str: + """Tira as etiquetas de um texto do catálogo destinado a virar aviso puro.""" + return re.sub(r"<[^>]+>", "", text) + + +class QuietThreadingHTTPServer(ThreadingHTTPServer): + """Servidor multi-thread que não polui o log quando o cliente desconecta antes da hora.""" + + daemon_threads = True + + def handle_error(self, request, client_address): + exc = sys.exc_info()[1] + if isinstance(exc, CLIENT_DISCONNECT_ERRORS): + return + super().handle_error(request, client_address) + + +class DashboardHandler(BaseHTTPRequestHandler): + """Rotas do painel, da autenticação à renderização, sem uma linha de HTML.""" + + # O cabeçalho Server ia na PRIMEIRA linha de toda resposta -- inclusive no + # 401, antes de qualquer autenticação -- anunciando "BaseHTTP/0.6 + # Python/3.14.7", ou seja, a versão exata do interpretador, logo acima da + # CSP e do X-Frame-Options que o resto do cabeçalho instala. Versão exata é + # o que um scanner precisa para escolher o exploit certo. + # + # version_string() também é sobrescrito porque o BaseHTTPRequestHandler + # concatena server_version + " " + sys_version: com sys_version vazio, a + # resposta sai com um espaço sobrando no fim do valor. + server_version = NOME_DO_PRODUTO + sys_version = "" + + def version_string(self) -> str: + return self.server_version + + settings: Optional[Settings] = None + cron_scheduler: Optional[Any] = None + # Quem entrou nesta requisição. Só o nome: a senha morre na conferência, e + # guardar o par inteiro a deixaria ao alcance de qualquer trecho de + # renderização. + authenticated_user: str = "" + db_path: str = "" + router_url: str = "" + sync_callback: Optional[Callable[[], Dict[str, Any]]] = None + + def log_message(self, format, *args): + # O log de acesso do http.server escreve em stderr sem passar pelo + # logger, e carrega a linha de requisição inteira. Silenciado. + return + + # -- autenticação ------------------------------------------------------- + + def check_auth(self) -> bool: + """Duas portas, e elas NÃO servem ao mesmo visitante. + + O cookie é a porta do navegador, e é a única que tem tranca do lado de + dentro: "Sair" apaga o cookie e acabou. O Basic Auth não tem logout -- + o navegador guarda a credencial e a reenvia sozinho até a janela + fechar, e não existe cabeçalho que mande ele esquecer. Enquanto a + navegação aceitava Basic, o botão Sair apagava o cookie e a próxima + visita entrava de novo pela outra porta: o botão mentia. + + Por isso quem pede HTML (um navegador) precisa de SESSÃO, e só. Quem + não pede HTML -- curl, script, monitoramento -- continua com Basic + Auth, que é o esquema que essas ferramentas sabem usar sem guardar + estado, e para as quais "sair" não quer dizer nada. + """ + if not self.settings: + return True + + usuario = sessao.usuario_da_sessao( + sessao.ler_do_cabecalho(self.headers.get("Cookie", "")) + ) + if usuario: + self.authenticated_user = usuario + return True + + if "text/html" in self.headers.get("Accept", ""): + return False + + cabecalho = self.headers.get("Authorization", "") + if not cabecalho.startswith("Basic "): + return False + try: + decodificado = base64.b64decode(cabecalho[6:].strip()).decode("utf-8") + except Exception: + return False + if ":" not in decodificado: + return False + user, password = decodificado.split(":", 1) + # Delega ao Settings: credenciais salvas, padrão de fábrica e a + # credencial de recuperação são avaliadas lá, num lugar só. + if not self.settings.verify_credentials(user, password): + return False + self.authenticated_user = user + return True + + def require_auth(self) -> bool: + """Deixa passar quem tem sessão ou Basic válido; responde o resto sozinha.""" + if self.check_auth(): + return True + + lang = self.resolve_language() + + # Quem pediu HTML é um navegador: mandamos para o formulário, que é + # página nossa -- traduzida, com a cara do painel e com logout. O 401 + # com WWW-Authenticate fica para quem NÃO pediu HTML (curl, scripts, + # monitoramento), que é quem sabe responder a ele. + if "text/html" in self.headers.get("Accept", "") and urlparse(self.path).path != "/login": + self.send_response(HTTPStatus.FOUND) + self.send_header("Location", "/login") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + return False + + corpo = render_notice_page( + translate("auth.required", lang), translate("auth.required_body", lang) + ) + self.send_response(HTTPStatus.UNAUTHORIZED) + self.send_header("WWW-Authenticate", f'Basic realm="{NOME_DO_PRODUTO}"') + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + return False + + def is_same_origin_request(self) -> bool: + """Recusa POST disparado por outro site. + + O Basic Auth é anexado automaticamente pelo navegador mesmo num POST + vindo de outra origem, e um formulário urlencoded não dispara preflight. + Sem esta checagem, uma página maliciosa aberta na mesma máquina poderia + trocar a senha do painel. + + A ordem importa: o Origin é a evidência forte e é avaliado primeiro. + Checar Sec-Fetch-Site antes disso fazia um valor inesperado do navegador + recusar a requisição mesmo com o Origin batendo com o Host. Não se usa + Referer porque a própria página é servida com Referrer-Policy: no-referrer. + """ + host = self.headers.get("Host", "") + origin = self.headers.get("Origin", "") + + if origin and origin != "null": + # Comparação pelo host declarado: mesma origem, requisição legítima. + # Origin presente e divergente é a única prova positiva de ataque. + return urlparse(origin).netloc == host + + fetch_site = self.headers.get("Sec-Fetch-Site", "") + if fetch_site: + # "none" é a navegação digitada na barra de endereços. + return fetch_site in ("same-origin", "none") + + # Cliente que não é navegador (curl, script): não há sessão a sequestrar. + return True + + # -- cabeçalhos --------------------------------------------------------- + + # Política de segurança aplicada a TODAS as respostas, não só à página + # principal: o 401, o aviso de credenciais trocadas e os redirects também + # são HTML que o navegador renderiza. + SECURITY_HEADERS = ( + ("Referrer-Policy", "no-referrer"), + ("X-Content-Type-Options", "nosniff"), + ("X-Frame-Options", "DENY"), + ( + "Content-Security-Policy", + # Restrita ao que a página realmente carrega: o Bootstrap e os + # ícones vêm do jsDelivr, as fontes do Google. connect-src 'self' + # porque o painel é inteiramente renderizado no servidor, então um + # HTML injetado não tem para onde exfiltrar. + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net " + "https://fonts.googleapis.com; " + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com data:; " + # As bandeiras do seletor de idioma são SVG que o CSS do + # flag-icons busca no mesmo CDN. Sem esta origem elas simplesmente + # não aparecem, e não há erro visível na tela para denunciar a falta. + "img-src 'self' data: https://cdn.jsdelivr.net; " + "connect-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "base-uri 'none'" + ), + ) + + def end_headers(self): + """Injeta os cabeçalhos de segurança antes de fechar o bloco. + + Aqui e não na rota: o 401 e a página de aviso também são HTML que o + navegador renderiza, e emiti-los só na página principal deixava + justamente essas duas sem proteção alguma. + """ + enviados = {nome for nome, _ in self._headers_buffer_names()} + for nome, valor in self.SECURITY_HEADERS: + if nome.lower() not in enviados: + self.send_header(nome, valor) + super().end_headers() + + def _headers_buffer_names(self): + """Nomes já enfileirados nesta resposta, para não duplicar cabeçalho.""" + for linha in getattr(self, "_headers_buffer", []) or []: + try: + texto = linha.decode("latin-1", "ignore") + except Exception: + continue + if ":" in texto: + yield texto.split(":", 1)[0].strip().lower(), texto + + # -- rotas -------------------------------------------------------------- + + # Rotas que este servidor conhece. Serve para uma só decisão, tomada ANTES + # de exigir sessão: o que não está aqui é 404, e não um convite a fazer + # login para depois descobrir que a página nunca existiu. + # + # Rota REAL e protegida continua mandando para /login -- é a diferença entre + # "você precisa entrar" e "isso não existe". + ROTAS_CONHECIDAS = { + "/", "/index.html", "/healthz", "/login", "/logout", "/robots.txt", + "/favicon.ico", "/credenciais-atualizadas", + } + PREFIXOS_CONHECIDOS = ("/api/", "/acoes/") + + def rota_existe(self, caminho: str) -> bool: + if caminho in self.ROTAS_CONHECIDAS or caminho.startswith(self.PREFIXOS_CONHECIDOS): + return True + # SEM CONFIGURAÇÃO, NADA MUDA: as rotas do acesso federado só existem + # quando há provedor configurado E ligado. Desligado, elas devolvem 404 + # pelo mesmo caminho de qualquer outra rota que nunca existiu -- um + # painel que nunca ligou SSO não tem nem superfície nova para sondar. + return caminho.startswith("/sso/") and self.sso_esta_ligado() + + def recusa_rota_desconhecida(self, caminho: str) -> bool: + """Devolve True e responde 404 quando a rota não existe neste servidor.""" + if self.rota_existe(caminho): + return False + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + return True + + def do_GET(self): + # Uma leitura de configuração por requisição: a conexão pode ser + # reaproveitada, e uma configuração salva no pedido anterior tem de + # valer no seguinte. + self._configuracao_sso = None + path = urlparse(self.path).path + if self.recusa_rota_desconhecida(path): + return + if path == "/healthz": + self.serve_healthz() + return + + # Público de propósito, e servido antes da sessão: um rastreador não tem + # credencial, e a única forma de ele ler a regra é ela não exigir uma. O + # painel não deve aparecer em índice de busca nenhum. + if path == "/robots.txt": + corpo = b"User-agent: *\nDisallow: /\n" + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.end_headers() + self.write_body(corpo) + return + + # A página de login é pública por definição: exigir sessão para exibir o + # formulário que cria a sessão seria um círculo fechado. + if path == "/login": + self.serve_login_page() + return + + # Servida ANTES do require_auth de propósito: o navegador ainda está com + # a senha antiga neste instante, e exigir autenticação aqui daria um 401 + # cru exatamente depois de a troca ter dado certo. + if path == "/credenciais-atualizadas": + self.serve_credentials_updated() + return + + # A ida ao provedor de identidade e a volta dele acontecem SEM sessão -- + # é a sessão que elas existem para criar, e quem chama a volta é o + # provedor, que não tem cookie nosso para apresentar. Todas passam pelo + # MESMO teto por endereço do formulário de login, e todas só existem + # quando o acesso federado está ligado (ver `rota_existe`). + if path == "/sso/oidc/iniciar": + self.inicia_oidc() + return + if path == "/sso/oidc/callback": + self.recebe_oidc() + return + + if path == "/sso/saml/iniciar": + self.inicia_saml() + return + + if path == "/favicon.ico": + self.serve_favicon() + return + + if not self.require_auth(): + return + + if path in ("/", "/index.html"): + self.serve_dashboard(parse_qs(urlparse(self.path).query)) + elif path == "/api/status": + self.serve_api_status() + elif path == "/sso/saml/metadata": + # Protegida de propósito: ver serve_saml_metadata. + self.serve_saml_metadata() + elif path == "/api/cron-status": + self.serve_cron_status() + else: + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + + def do_POST(self): + self._configuracao_sso = None + rota_inicial = urlparse(self.path).path + if rota_inicial == "/login": + self.handle_login() + return + if rota_inicial == "/logout": + self.handle_logout() + return + + if rota_inicial == "/sso/saml/acs": + self.recebe_saml() + return + + if not self.require_auth(): + return + + if not self.is_same_origin_request(): + # Redireciona com aviso em vez de devolver um 403 cru: o operador + # precisa entender o que houve, e um 403 na tela depois de tentar + # trocar a senha parece um defeito do painel. + self.redirect_to_dashboard( + "danger", translate("security.cross_origin", self.resolve_language()) + ) + return + + tamanho = int(self.headers.get("Content-Length") or 0) + corpo = self.rfile.read(tamanho) if tamanho else b"" + campos = parse_qs(corpo.decode("utf-8", errors="replace")) + + route = urlparse(self.path).path + # Ações do painel: executam e redirecionam de volta para a página + # renderizada (POST-Redirect-GET), sem JSON no navegador. O aviso viaja + # na querystring e o jQuery do `render.py` o apaga da barra de endereços + # assim que a página desenha -- senão o F5 traria de volta a mensagem de + # algo que já aconteceu. + if route.startswith("/acoes/"): + self.handle_dashboard_action(route, campos) + return + if route == "/api/sync": + self.handle_sync_request() + elif route == "/api/test-gateway": + self.handle_test_gateway() + elif route == "/api/change-password": + self.handle_change_password(corpo) + elif route == "/api/cron-run": + self.handle_api_cron_run() + else: + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + + # -- respostas ---------------------------------------------------------- + + def write_body(self, corpo: bytes) -> None: + """Escreve o corpo tolerando o cliente ter fechado a conexão antes da leitura.""" + try: + self.wfile.write(corpo) + except CLIENT_DISCONNECT_ERRORS: + # O navegador fechou antes de ler. Não é erro do servidor, e deixar + # subir enchia o log de traceback a cada recarga cancelada. + self.close_connection = True + + def respond_html(self, corpo: bytes, status: HTTPStatus = HTTPStatus.OK) -> None: + """Resposta HTML completa: Content-Length montado antes de qualquer escrita.""" + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + def respond_json(self, corpo: bytes) -> None: + """Resposta JSON. Nunca é cacheável: o que ela carrega é estado vivo.""" + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + def redirect_to_dashboard(self, tone: str, message: str) -> None: + """Volta para a página com uma mensagem de resultado (POST-Redirect-GET).""" + query = urlencode({"aviso": message, "tom": tone}) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/?{query}") + self.send_header("Content-Length", "0") + self.end_headers() + + def responde_429(self, espere_segundos: int) -> None: + """Pedidos demais: 429 com Retry-After, que é o que um cliente correto lê.""" + lang = self.resolve_language() + corpo = render_notice_page( + translate("auth.too_many", lang), + translate("auth.too_many_body", lang, seconds=espere_segundos), + ) + self.send_response(HTTPStatus.TOO_MANY_REQUESTS) + self.send_header("Retry-After", str(espere_segundos)) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + def serve_credentials_updated(self) -> None: + """Confirma a troca de senha sem exigir a credencial que acabou de mudar.""" + lang = self.resolve_language() + self.respond_html( + render_notice_page( + translate("auth.updated_title", lang), + translate("auth.updated_body", lang), + translate("auth.updated_link", lang), + ) + ) + + def serve_cron_status(self) -> None: + """Estado do agendador. O histórico só traz contagens e ações, nunca credencial.""" + cron = self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False} + self.respond_json(json.dumps(cron, ensure_ascii=False, indent=2).encode("utf-8")) + + def serve_healthz(self) -> None: + """Health check do Docker: barato, sem cache do navegador e sem exceção no log. + + A sondagem ao gateway passa por probe_gateway, que memoriza o resultado; + sem isso cada probe pagava até 3s de HTTP de saída e estourava o timeout + do healthcheck, que fechava o socket e gerava BrokenPipeError. + """ + db_ok = bool(self.db_path and os.path.exists(self.db_path)) + gateway_ok = self.probe_gateway() + + if db_ok and gateway_ok: + status, corpo = HTTPStatus.OK, b"OK" + elif not db_ok: + status, corpo = HTTPStatus.SERVICE_UNAVAILABLE, b"DATABASE_NOT_READY" + else: + status, corpo = HTTPStatus.SERVICE_UNAVAILABLE, b"ROUTER_SERVICE_UNREACHABLE" + + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + # -- entrada ------------------------------------------------------------ + + + def serve_favicon(self) -> None: + """204: o ícone da aba vem do data URI embutido, não de um arquivo. + + Servir o SVG por aqui o deixaria atrás da autenticação, e a aba ficaria + sem ícone até o operador entrar -- foi por isso que ele virou data URI. + Mas devolver 404 faria o navegador registrar um erro em toda visita, já + que ele pede `/favicon.ico` por conta própria. 204 encerra a conversa + sem corpo e sem erro. + """ + self.send_response(HTTPStatus.NO_CONTENT) + for nome, valor in self.SECURITY_HEADERS: + self.send_header(nome, valor) + self.send_header("Cache-Control", "public, max-age=86400") + self.send_header("Content-Length", "0") + self.end_headers() + + def pagina_de_login(self, lang: str, erro: str = "", com_desafio: bool = True) -> bytes: + """Monta o formulário de entrada com o desafio que o endereço merece. + + O desafio só entra depois de algumas falhas: quem acerta de primeira + nunca o vê, e quem insiste passa a pagar CPU por tentativa. + + `com_desafio=False` é a recusa do acesso federado, e o motivo é o + oposto: lá a página tem de sair IDÊNTICA para toda falha, e um desafio + sorteado a cada recusa mudaria o corpo -- que é exatamente o sinal que + conta ao atacante em que ponto do fluxo ele parou. + """ + endereco = protecao.endereco_do_cliente(self.client_address) + desafio, dificuldade = "", protecao.DIFICULDADE + if com_desafio and protecao.precisa_de_desafio(endereco): + desafio = protecao.novo_desafio() + dificuldade = protecao.dificuldade_para(endereco) + return render_login_page(lang, erro, desafio, dificuldade, self.nome_do_provedor_sso()) + + def serve_login_page(self, erro: str = "") -> None: + """Formulário de entrada: a porta do navegador para o painel.""" + self.respond_html(self.pagina_de_login(self.resolve_language(), erro)) + + def handle_login(self) -> None: + """Valida a credencial do formulário e emite o cookie de sessão.""" + endereco = protecao.endereco_do_cliente(self.client_address) + + # Teto por janela: o que para o script que tenta mil senhas por minuto. + pode, espere = protecao.registra_tentativa(endereco) + if not pode: + self.responde_429(espere) + return + + tamanho = int(self.headers.get("Content-Length", 0)) + corpo = self.rfile.read(tamanho) if tamanho > 0 else b"" + campos = parse_qs(corpo.decode("utf-8", "replace")) + usuario = (campos.get("usuario") or [""])[0] + senha = (campos.get("senha") or [""])[0] + + # Depois de algumas falhas, o formulário só é aceito com a prova de + # trabalho resolvida. Custa CPU para quem tenta em massa e é instantânea + # de conferir aqui. + if protecao.precisa_de_desafio(endereco): + desafio = (campos.get("desafio") or [""])[0] + resposta = (campos.get("resposta") or [""])[0] + if not protecao.resposta_confere( + desafio, resposta, protecao.dificuldade_para(endereco) + ): + protecao.anota_falha(endereco) + self.serve_login_page(translate("auth.login_failed", self.resolve_language())) + return + + # A espera cresce a cada falha seguida. É do lado do servidor: não há + # nada no cliente para desligar. + atraso = protecao.espera_por_falhas(endereco) + if atraso: + time.sleep(atraso) + + if not self.settings or not self.settings.verify_credentials(usuario, senha): + # Mensagem única para usuário errado e senha errada: distinguir os + # dois conta a quem tenta qual metade já acertou. + protecao.anota_falha(endereco) + self.serve_login_page(translate("auth.login_failed", self.resolve_language())) + return + + protecao.limpa_apos_sucesso(endereco) + self.send_response(HTTPStatus.FOUND) + self.send_header("Location", "/") + self.send_header("Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir(usuario))) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + + def handle_logout(self) -> None: + """Apaga o cookie. O Basic Auth não tem equivalente disso.""" + self.send_response(HTTPStatus.FOUND) + self.send_header("Location", "/login") + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar()) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + + # -- acesso federado ---------------------------------------------------- + # + # As rotas públicas a mais, e todas passam pelo MESMO teto por endereço do + # formulário de login: a ida ao provedor e a volta dele acontecem sem + # sessão -- é a sessão que elas existem para criar. + + def freio_do_sso(self) -> bool: + """Aplica o teto por endereço. Devolve True quando já respondeu 429.""" + endereco = protecao.endereco_do_cliente(self.client_address) + pode, espere = protecao.registra_tentativa(endereco) + if not pode: + self.responde_429(espere) + return True + return False + + def anota_falha_de_sso(self, motivo: Any) -> None: + """O motivo vai para o log interno; a tela recebe a mensagem genérica. + + Nada do que passa por aqui carrega credencial: nem o `code`, nem os + tokens, nem o segredo do cliente. O que se registra é o passo que falhou. + """ + get_logger().warning("[SSO] fluxo recusado: %s", motivo) + + def recusa_sso(self) -> None: + """Mensagem ÚNICA para toda falha do fluxo federado. + + Distinguir "state trocado" de "e-mail fora da lista" conta ao atacante + em que ponto do fluxo ele parou. O formulário local vem junto: quem tem + senha entra mesmo com o provedor recusando. + + O cookie de estado é de uso único: recusada a volta, ele sai junto, para + que uma segunda tentativa com o mesmo `state` não encontre nada com que + comparar. + """ + lang = self.resolve_language() + corpo = self.pagina_de_login(lang, translate("sso.failed", lang), com_desafio=False) + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado()) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + def pousa_sessao_federada(self, email: str) -> None: + """Emite o MESMO cookie assinado do formulário e aterrissa em "/". + + NÃO é um 302: no Chrome, uma cadeia de redirecionamento iniciada em + outro site não carrega o cookie `SameSite=Strict` no salto seguinte, e o + operador cairia em `/login` com uma sessão válida no bolso. + + O destino é SEMPRE "/". Nenhum parâmetro de retorno vira destino, aqui + ou em qualquer lugar: isso seria redirecionamento aberto autenticado. + """ + lang = self.resolve_language() + corpo = self.pagina_de_pouso(lang) + # O prefixo "sso:" distingue no rodapé e no log quem entrou pela porta + # federada, sem inventar uma segunda forma de sessão: o cookie é o mesmo. + get_logger().info("[SSO] sessão emitida para uma identidade federada") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header( + "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir("sso:" + email)) + ) + # O cookie de ida já cumpriu o papel: uso único. + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado()) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + def confere_senha_local(self, senha: str) -> bool: + """Confere a senha do PAINEL, nunca a identidade federada da sessão. + + Quem entrou pelo provedor de identidade não tem senha local -- e é + exatamente por isso que ela é exigida ao salvar a configuração: é o que + um cookie sequestrado não entrega. A credencial de recuperação entra + sempre, com provedor vivo ou morto: é ela que salva quem precisa + DESLIGAR o acesso federado e não lembra a senha do painel. + """ + if not self.settings or not senha: + return False + usuario = self.authenticated_user or getattr(self.settings, "dashboard_user", "admin") + if self.settings.verify_credentials(usuario, senha): + return True + return self.settings.verify_credentials("admin", senha) + + def configuracao_sso(self) -> Optional[Dict[str, Any]]: + """Configuração em vigor, ou None quando o acesso federado não deve funcionar. + + A leitura é barata mas não é de graça, e o mesmo pedido a consulta na + autenticação, na tela e no despacho: memorizada por requisição. + """ + guardada = getattr(self, "_configuracao_sso", None) + if guardada is None and self.settings: + guardada = sso.configuracao_efetiva(self.prefs_path(), self.sso_base_dir()) + self._configuracao_sso = guardada + return guardada + + def sso_esta_ligado(self) -> bool: + """Há provedor configurado E completo? É o que decide se as rotas existem.""" + return bool(self.configuracao_sso()) + + def sso_base_dir(self) -> str: + """Diretório do segredo do cliente: o mesmo das credenciais locais. + + Nunca $HOME por atalho -- o segredo tem de cair no volume de dados, ou + ele some quando o container é recriado e o SSO se desliga sozinho. + """ + if not self.settings: + return "" + return os.path.dirname(self.settings.get_auth_file_path()) + + def nome_do_provedor_sso(self) -> str: + """Nome exibido no botão da tela de login. Vazio quando não há botão. + + A descoberta é consultada aqui de propósito: se o provedor de identidade + não responde, o botão SOME em vez de levar a uma falha genérica. O + formulário local nunca sai da tela. + """ + config = self.configuracao_sso() + if not config or not sso.descobre(str(config["issuer"])): + return "" + return sso.nome_do_provedor(config) + + def pagina_de_pouso(self, lang: str) -> bytes: + """A tela intermediária que carrega o cookie recém-emitido.""" + return render_landing_page(lang) + + def inicia_oidc(self) -> None: + """Sorteia o estado, grava o cookie de ida e manda o navegador ao provedor.""" + if self.freio_do_sso(): + return + config = self.configuracao_sso() + if not config: + self.recusa_sso() + return + + documento = sso.descobre(str(config["issuer"])) + if not documento: + # Descoberta quebrada não trava o login local: a tela volta com o + # formulário de sempre. + self.anota_falha_de_sso("descoberta do emissor indisponível") + self.recusa_sso() + return + + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + verificador = sso.novo_verificador() + self.send_response(HTTPStatus.FOUND) + self.send_header( + "Location", sso.url_de_autorizacao(config, documento, state, nonce, verificador) + ) + self.send_header( + "Set-Cookie", + sessao.cabecalho_para_gravar_estado( + sessao.emitir_estado_sso(state, nonce, verificador) + ), + ) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + + def recebe_oidc(self) -> None: + """Volta do provedor. Valida TUDO antes de emitir sessão.""" + if self.freio_do_sso(): + return + config = self.configuracao_sso() + if not config: + self.recusa_sso() + return + + estado = sessao.ler_estado_sso( + sessao.ler_estado_do_cabecalho(self.headers.get("Cookie", "")) + ) + parametros = { + chave: valores[0] + for chave, valores in parse_qs(urlparse(self.path).query).items() + if valores + } + + email, motivo = sso.conclui_login(config=config, estado=estado, parametros=parametros) + if not email: + self.anota_falha_de_sso(motivo) + protecao.anota_falha(protecao.endereco_do_cliente(self.client_address)) + self.recusa_sso() + return + + protecao.limpa_apos_sucesso(protecao.endereco_do_cliente(self.client_address)) + self.pousa_sessao_federada(email) + + def inicia_saml(self) -> None: + """AuthnRequest por HTTP-Redirect binding, com o ID guardado no servidor.""" + if self.freio_do_sso(): + return + config = self.configuracao_sso() + if not config.esta_ligado() or config.provedor != "saml": + self.recusa_sso() + return + identificador = sso.novo_id_de_requisicao() + # No SERVIDOR, e não em cookie: o ACS é um POST vindo de outro site, e + # `SameSite=Lax` não viaja em POST cross-site. + sso.registra_pendente(identificador) + self.send_response(HTTPStatus.FOUND) + self.send_header( + "Location", + sso.url_de_ida_saml(config, sso.monta_authn_request(config, identificador)), + ) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + + def recebe_saml(self) -> None: + """ACS: recebe a asserção do provedor e valida antes de emitir sessão. + + Não passa pela guarda de mesma origem, e não precisa: por definição este + POST vem de outro site, e a autenticidade vem da assinatura XML e do + `InResponseTo`, não do cabeçalho Origin. + """ + if self.freio_do_sso(): + return + config = self.configuracao_sso() + if not config.esta_ligado() or config.provedor != "saml": + self.recusa_sso() + return + + # O corpo é lido AQUI, dentro do handler -- nunca no despacho, que roda + # antes de qualquer decisão sobre quem está do outro lado. + tamanho = int(self.headers.get("Content-Length") or 0) + corpo = self.rfile.read(tamanho) if tamanho else b"" + campos = parse_qs(corpo.decode("utf-8", errors="replace")) + + try: + resposta = (campos.get("SAMLResponse") or [""])[0] + if not resposta: + raise sso.FalhaDeSSO("POST no ACS sem SAMLResponse") + identificador = sso.in_response_to(resposta) + if not sso.consome_pendente(identificador): + raise sso.FalhaDeSSO("InResponseTo desconhecido, gasto ou fora do prazo") + email = sso.processa_resposta_saml(config, resposta, identificador) + if not sso.email_autorizado(email, config): + raise sso.FalhaDeSSO("e-mail fora da lista de autorizados") + except sso.FalhaDeSSO as erro: + self.anota_falha_de_sso(erro) + self.recusa_sso() + return + + protecao.limpa_apos_sucesso(protecao.endereco_do_cliente(self.client_address)) + self.pousa_sessao_federada(email) + + def serve_saml_metadata(self) -> None: + """Descrição do serviço, servida SÓ com sessão. + + Não aumenta a lista de rotas públicas: o operador baixa o arquivo + autenticado e o entrega ao provedor, e não há pressa nenhuma nisso. + """ + try: + corpo = sso.metadata_do_sp(self.configuracao_sso()).encode("utf-8") + except sso.FalhaDeSSO: + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/xml; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + def sso_view(self) -> Dict[str, Any]: + """O que a tela de configuração precisa saber. NUNCA o segredo do cliente. + + Um GET de configuração jamais devolve o valor gravado: a tela recebe + apenas a informação de que EXISTE um segredo, e um campo para substituí-lo. + """ + if not self.settings: + return {} + config = sso.ler_configuracao(self.prefs_path()) + base = sso.normaliza_base_url(config.get("base_url", "")) + return { + "config": config, + "tem_segredo": bool(sso.ler_segredo(self.sso_base_dir())), + "segredo_do_ambiente": sso.segredo_vem_do_ambiente(), + "desligado_por_ambiente": sso.desligado_por_ambiente(), + "saml_disponivel": sso.saml_disponivel(), + "callback_url": f"{base}{sso.ROTA_CALLBACK}" if base else "", + } + + def handle_sso_settings(self, campos: Dict[str, List[str]]) -> None: + """Grava a configuração do acesso federado. Exige a senha local atual. + + Três trancas, e as três são necessárias: sessão (do `do_POST`), guarda + de mesma origem (idem) e a SENHA LOCAL ATUAL, pedida aqui. A terceira + existe porque quem sequestra uma sessão de oito horas poderia apontar o + painel para um provedor hostil e se pôr na lista de autorizados -- + persistência permanente ganha com um cookie roubado. + """ + lang = self.resolve_language() + if self.freio_do_sso(): + return + + def campo(nome: str) -> str: + return (campos.get(nome, [""])[0] or "").strip() + + if not self.confere_senha_local(campo("senha_atual")): + protecao.anota_falha(protecao.endereco_do_cliente(self.client_address)) + self.redirect_to_dashboard("danger", translate("sso.wrong_password", lang)) + return + + if sso.desligado_por_ambiente(): + self.redirect_to_dashboard("warning", translate("sso.disabled_by_env", lang)) + return + + novo = { + "enabled": campo("enabled"), + "base_url": campo("base_url"), + "issuer": campo("issuer"), + "client_id": campo("client_id"), + "scopes": campo("scopes"), + "allowed_domains": campo("allowed_domains"), + "allowed_emails": campo("allowed_emails"), + } + if novo["enabled"] not in sso.PROVEDORES: + self.redirect_to_dashboard("danger", translate("sso.save_failed", lang)) + return + + base_dir = self.sso_base_dir() + novo_segredo = campo("client_secret") + if novo_segredo and not sso.segredo_vem_do_ambiente(): + if not sso.grava_segredo(base_dir, novo_segredo): + self.redirect_to_dashboard("danger", translate("sso.secret_failed", lang)) + return + # Campo em branco MANTÉM o segredo anterior. Quem reabre a tela para + # corrigir a lista de permissão não digita o segredo de novo, e apagar o + # que funciona por causa de um campo vazio seria desligar o SSO em + # silêncio. + if novo["enabled"] == "oidc": + problemas = sso.problemas_da_configuracao(novo, bool(sso.ler_segredo(base_dir))) + if problemas: + self.redirect_to_dashboard( + "danger", " ".join(translate(chave, lang) for chave in problemas) + ) + return + + if not sso.grava_configuracao(self.prefs_path(), novo): + self.redirect_to_dashboard("danger", translate("sso.save_failed", lang)) + return + + # O emissor pode ter mudado: o documento memorizado do anterior não vale + # mais nada. + sso.limpa_cache_descoberta() + self._configuracao_sso = None + get_logger().info("[SSO] configuração atualizada") + self.redirect_to_dashboard("success", translate("sso.saved", lang)) + + # -- preferências ------------------------------------------------------- + + def prefs_path(self) -> str: + """Banco de preferências próprio do sincronizador, nunca o do gateway.""" + return self.settings.get_prefs_path() if self.settings else "" + + def resolve_language(self) -> str: + """Idioma em vigor: preferência salva no SQLite, senão o padrão (inglês).""" + return normalize_language( + get_preference(self.prefs_path(), "language", DEFAULT_LANGUAGE) + ) + + # -- ações -------------------------------------------------------------- + + def invalidate_caches(self) -> None: + """Descarta o que foi memorizado para que a próxima renderização releia tudo. + + Sem isto, o resultado da sondagem ao gateway continuaria valendo por até + GATEWAY_PROBE_TTL_SECONDS e o painel exibiria um estado anterior à ação + que o operador acabou de disparar. + """ + with _gateway_probe_lock: + _gateway_probe_cache.clear() + esquece_o_catalogo() + + def handle_dashboard_action(self, route: str, campos: Dict[str, List[str]]) -> None: + """Executa uma ação do painel e devolve o operador à página renderizada.""" + if route == "/acoes/atualizar": + # Só recarrega a tela: zera o que foi memorizado e volta para a + # página, que é montada de novo no servidor. Quem roda um ciclo é + # "Sync now", que aponta para /acoes/cron -- dois botões vizinhos + # parecendo fazer a mesma coisa faziam o operador escolher no escuro. + self.invalidate_caches() + self.redirect_to_dashboard( + "info", translate("action.refreshed", self.resolve_language()) + ) + elif route == "/acoes/cron": + self.handle_cron_action() + elif route == "/acoes/idioma": + self.handle_language(campos) + elif route == "/acoes/testar-gateway": + self.handle_gateway_test() + elif route == "/acoes/credenciais": + self.handle_credentials(campos) + elif route == "/acoes/sso": + self.handle_sso_settings(campos) + else: + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + + def handle_language(self, campos: Dict[str, List[str]]) -> None: + """Grava o idioma escolhido e volta para a raiz limpa.""" + escolhido = normalize_language((campos.get("lang", [""])[0] or "").strip()) + # Gravar pode falhar -- disco cheio, arquivo sem permissão de escrita. + # Redirecionar com sucesso nesse caso deixava o operador clicando na + # bandeira sem entender por que a tela volta no idioma anterior: o + # painel dizia "pronto" e nada acontecia. + if not set_preference(self.prefs_path(), "language", escolhido): + self.redirect_to_dashboard("danger", translate("language.save_failed", escolhido)) + return + # Sem aviso na volta: repetir a mensagem da ação anterior depois de + # trocar de idioma a mostraria no idioma antigo. + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + + def handle_cron_action(self) -> None: + """Dispara o agendador agora, registrando a execução no histórico dele. + + É a ÚNICA rota que roda um ciclo sob demanda. Havia também + `/acoes/sincronizar`, que fazia exatamente o mesmo trabalho por fora do + agendador: dois botões para a mesma ação, e o ciclo disparado pelo + primeiro não aparecia no histórico que a tela mostra. + """ + lang = self.resolve_language() + if not self.cron_scheduler: + self.redirect_to_dashboard("warning", translate("cron.unavailable", lang)) + return + try: + entry = self.cron_scheduler.trigger_now() or {} + except Exception as erro: + self.redirect_to_dashboard("danger", translate("cron.failed", lang, error=erro)) + return + # O ciclo mexe no estado do gateway: o que ficou memorizado antes dele + # deixaria a tela mostrando o mundo anterior ao clique. + self.invalidate_caches() + self.redirect_to_dashboard( + "success" if entry.get("success", True) else "warning", + translate( + "action.cron_ran", + lang, + duration=entry.get("durationMs", 0), + inspected=entry.get("totalInspected", 0), + # O nome do campo ainda muda entre os irmãos -- `findingsCount` + # de um lado, `refreshedCount` do outro. Unificá-lo é trabalho + # do `cron.py`, não daqui. + findings=entry.get("findingsCount", entry.get("refreshedCount", 0)), + ), + ) + + def handle_gateway_test(self) -> None: + """Sonda o gateway agora, sem esperar o que estava memorizado expirar.""" + lang = self.resolve_language() + self.invalidate_caches() + online = self.probe_gateway() + self.redirect_to_dashboard( + "success" if online else "danger", + f'{translate("gateway.title", lang)}: ' + f'{"ONLINE" if online else translate("gateway.no_response", lang)}', + ) + + def handle_credentials(self, campos: Dict[str, List[str]]) -> None: + """Troca usuário e senha do painel, com a política de força inteira.""" + lang = self.resolve_language() + user = (campos.get("user", [""])[0] or "").strip() + password = (campos.get("password", [""])[0] or "").strip() + + # Todas as regras violadas de uma vez: uma por tentativa faria o + # operador descobrir a política aos poucos. + problemas = self.settings.check_password_strength(password) if self.settings else [] + if problemas: + self.redirect_to_dashboard( + "danger", " ".join(translate(chave, lang) for chave in problemas) + ) + return + if self.settings and getattr(self.settings, "dashboard_auth_from_env", False): + # O mesmo texto do modal, sem a marcação: o aviso é escapado antes + # de ir para a tela, e as etiquetas apareceriam cruas ali. + self.redirect_to_dashboard("warning", strip_markup(translate("auth.env_managed", lang))) + return + if self.settings and self.settings.update_auth_credentials(user, password): + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/credenciais-atualizadas") + self.send_header("Content-Length", "0") + self.end_headers() + return + self.redirect_to_dashboard("danger", translate("auth.save_failed", lang)) + + # -- gateway ------------------------------------------------------------ + + def probe_gateway(self) -> bool: + """Sonda o gateway com cache: o resultado vale por GATEWAY_PROBE_TTL_SECONDS. + + Quem faz a pergunta é o `gateway.py`, que sabe o endereço e o que conta + como "respondeu" NESTE gateway. Aqui fica só o cache -- e ele é comum + aos três, porque o /healthz é chamado a cada 15s pelo Docker e sem cache + cada chamada pagaria uma requisição de saída de até 3s, estourando o + timeout do probe. + """ + alvo = self.gateway_url() + if not alvo: + return True + + agora = time.time() + with _gateway_probe_lock: + medido_em, resultado = _gateway_probe_cache.get(alvo, (0.0, None)) + if resultado is not None and (agora - medido_em) < GATEWAY_PROBE_TTL_SECONDS: + return resultado + + respondeu = self.sonda_o_gateway(alvo) + + with _gateway_probe_lock: + _gateway_probe_cache[alvo] = (time.time(), respondeu) + return respondeu + + def gateway_url(self) -> str: + """Endereço do gateway que este painel acompanha.""" + return self.router_url + + def sonda_o_gateway(self, alvo: str) -> bool: + """Pergunta ao `gateway.py` se o gateway respondeu. A regra é dele.""" + return bool(sondar(alvo)["online"]) + + def contagem_do_banco(self) -> Dict[str, int]: + """Quantas conexões e combos o banco do gateway tem agora.""" + return (carregar_painel(self.settings) if self.settings else {}).get("counters", {}) + + # -- renderização ------------------------------------------------------- + + def collect_dashboard_state(self) -> Dict[str, Any]: + """Lê tudo o que a página precisa. Roda no servidor: o SQLite nunca sai daqui.""" + # UMA chamada ao `gateway.py`, com as chaves de sempre: daqui para baixo + # o painel não sabe se o gateway guarda isso em SQLite ou responde por HTTP. + painel = carregar_painel(self.settings) if self.settings else { + "connections": [], "keys": [], "models": [], "combos": [], + "findings": [], "counters": {}, "probe": {}, + "model_states": {"catalog": CATALOGO_INACESSIVEL, "db": "missing"}, + } + conns = painel["connections"] + combos = painel["combos"] + + inicio = time.time() + online = self.probe_gateway() + latency_ms = int((time.time() - inicio) * 1000) + + return { + "connections": conns, + "combos": combos, + "keys": painel["keys"], + "models": painel["models"], + "modelsState": painel["model_states"]["catalog"], + "cron": self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False}, + "gateway": { + "url": self.gateway_url(), + "online": online, + "statusCode": 200 if online else 0, + "latencyMs": latency_ms, + # Bandeira explícita: o resumo é texto para humano e vinha + # sempre preenchido, inclusive com "Banco não encontrado". + # Converter esse texto em booleano fazia a tela declarar banco e + # gateway 100% operacionais justamente quando o arquivo sumia. + "dbOk": painel["model_states"]["db"] == "ok", + # Números, e não frase pronta: quem conhece o idioma escolhido é + # o render. Enquanto a frase nascia aqui, a tela em inglês + # exibia "Operacional (0 conexões, 0 combos)". + "dbConnections": len(conns), + "dbCombos": len(combos), + }, + } + + def serve_dashboard(self, query: Dict[str, List[str]]) -> None: + """Renderiza a página inteira no servidor, com os dados já embutidos.""" + estado = self.collect_dashboard_state() + + flash = None + aviso = (query.get("aviso", [""])[0] or "").strip() + if aviso: + flash = {"message": aviso, "tone": (query.get("tom", ["info"])[0] or "info").strip()} + + current_user, is_default, auth_from_env, refresh_margin = "admin", False, False, 900 + if self.settings: + current_user = self.authenticated_user or self.settings.dashboard_user + is_default = self.settings.is_default_password() + auth_from_env = getattr(self.settings, "dashboard_auth_from_env", False) + refresh_margin = self.settings.refresh_margin + + conteudo = render_dashboard( + sso_view=self.sso_view(), + connections=estado["connections"], + combos=estado["combos"], + keys=estado["keys"], + models=estado["models"], + models_state=estado["modelsState"], + cron=estado["cron"], + gateway=estado["gateway"], + db_path=self.db_path, + router_url=self.gateway_url(), + current_user=current_user, + is_default_password=is_default, + refresh_margin=refresh_margin, + auth_from_env=auth_from_env, + flash=flash, + lang=self.resolve_language(), + ).encode("utf-8") + + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + # A página carrega dados vivos: nunca pode vir do cache do navegador. + self.send_header("Cache-Control", "no-store, must-revalidate") + self.send_header("Content-Length", str(len(conteudo))) + self.end_headers() + self.write_body(conteudo) + + def serve_api_status(self) -> None: + """Projeção explícita: só os campos que o painel realmente consome. + + A MESMA costura da tela, e não uma segunda leitura do gateway -- com + duas leituras as duas superfícies divergiriam sem ninguém notar. + """ + painel = carregar_painel(self.settings) if self.settings else { + "connections": [], "combos": [] + } + payload = { + "status": "online", + "gatewayUrl": self.gateway_url(), + "dbPath": self.db_path, + "currentUser": self.authenticated_user or "admin", + "isDefaultPassword": self.settings.is_default_password() if self.settings else False, + "cron": self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False}, + "connections": [ + { + "id": c.id, + "provider": c.provider, + "name": c.name, + "isOAuth": c.is_oauth, + "hasApiKey": c.has_api_key, + # `getattr` porque o registro de conexao ainda nao e o mesmo + # nos tres: um declara `is_local`, o outro `updated_at`, e + # perder o campo seria quebrar quem le esta API la fora. + "isLocal": getattr(c, "is_local", False), + "expiresAtMs": c.expires_at_ms, + "remainingSeconds": c.remaining_seconds, + "healthStatus": c.health_status, + "updatedAt": getattr(c, "updated_at", None), + } + for c in painel["connections"] + ], + "combos": painel["combos"], + } + self.respond_json(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")) + + # -- endpoints JSON ----------------------------------------------------- + # + # Nenhum deles é desenhado na tela: existem para script, monitoramento e + # diagnóstico, que é quem sabe falar Basic Auth sem guardar estado. + + def handle_sync_request(self) -> None: + """Dispara uma sincronização e devolve o resultado cru.""" + if not self.sync_callback: + self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, "Synchronizer unavailable") + return + try: + resultado = self.sync_callback() + except Exception as erro: + self.responde_erro_json(str(erro)) + return + self.invalidate_caches() + self.respond_json( + json.dumps({"success": True, "result": resultado}, ensure_ascii=False).encode("utf-8") + ) + + def handle_api_cron_run(self) -> None: + """Roda um ciclo do agendador. Sem agendador, cai na sincronização direta.""" + if not self.cron_scheduler: + self.handle_sync_request() + return + try: + entry = self.cron_scheduler.trigger_now() + except Exception as erro: + self.responde_erro_json(str(erro)) + return + self.invalidate_caches() + self.respond_json( + json.dumps({"success": True, "cycle": entry}, ensure_ascii=False).encode("utf-8") + ) + + def handle_test_gateway(self) -> None: + """Diagnóstico do gateway e do banco, em uma resposta só.""" + inicio = time.time() + alvo = self.gateway_url() + gateway_ok = False + status_code = 0 + gateway_err = "" + + try: + req = urllib.request.Request( + alvo, headers={"User-Agent": f"{NOME_DO_PRODUTO}-Tester/1.0"} + ) + with urllib.request.urlopen(req, timeout=5.0) as resp: + status_code = resp.status + gateway_ok = status_code < 500 + except urllib.error.HTTPError as erro: + status_code = erro.code + gateway_ok = erro.code < 500 + except Exception as erro: + gateway_err = str(erro) + + latency_ms = int((time.time() - inicio) * 1000) + db_exists = bool(self.db_path and os.path.exists(self.db_path)) + contagem = self.contagem_do_banco() if db_exists else {} + + resultado = { + "success": gateway_ok and db_exists, + "gatewayUrl": alvo, + "gatewayStatus": "online" if gateway_ok else "offline", + "httpStatusCode": status_code, + "latencyMs": latency_ms, + "gatewayError": gateway_err if not gateway_ok else None, + "dbStatus": "ok" if db_exists else "not_found", + "dbPath": self.db_path, + "connectionsCount": contagem.get("connections", 0), + "combosCount": contagem.get("combos", 0), + "message": ( + f"{NOME_DO_GATEWAY}: OK" + if (gateway_ok and db_exists) + else f"{NOME_DO_GATEWAY}: FAIL" + ), + } + self.respond_json(json.dumps(resultado, ensure_ascii=False, indent=2).encode("utf-8")) + + def handle_change_password(self, corpo: bytes) -> None: + """Troca a credencial por JSON, com a MESMA política de força da tela.""" + lang = self.resolve_language() + try: + dados = json.loads(corpo.decode("utf-8")) if corpo else {} + except Exception as erro: + self.responde_erro_json(str(erro)) + return + + novo_usuario = str(dados.get("newUser") or "admin").strip() + nova_senha = str(dados.get("newPassword") or "").strip() + + problemas = self.settings.check_password_strength(nova_senha) if self.settings else [] + if problemas: + detalhe = " ".join(translate(chave, lang) for chave in problemas) + self.responde_erro_json(detalhe, HTTPStatus.BAD_REQUEST) + return + if self.settings and getattr(self.settings, "dashboard_auth_from_env", False): + self.responde_erro_json( + strip_markup(translate("auth.env_managed", lang)), HTTPStatus.CONFLICT + ) + return + if self.settings and self.settings.update_auth_credentials(novo_usuario, nova_senha): + self.respond_json( + json.dumps( + {"success": True, "newUser": novo_usuario}, ensure_ascii=False + ).encode("utf-8") + ) + return + self.responde_erro_json(translate("auth.save_failed", lang)) + + def responde_erro_json( + self, detalhe: str, status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR + ) -> None: + """Erro em JSON. O detalhe nunca carrega credencial: quem o monta é quem falhou.""" + corpo = json.dumps({"success": False, "error": detalhe}, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(corpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.write_body(corpo) + + +def start_web_server( + host: str, + port: int, + db_path: str, + router_url: str = "", + sync_callback: Optional[Callable[[], Dict[str, Any]]] = None, + settings: Optional[Settings] = None, + cron_scheduler: Optional[Any] = None, +) -> ThreadingHTTPServer: + """Sobe o painel numa thread própria e devolve o servidor.""" + DashboardHandler.db_path = db_path + DashboardHandler.router_url = router_url + DashboardHandler.sync_callback = sync_callback + DashboardHandler.settings = settings + DashboardHandler.cron_scheduler = cron_scheduler + + server = QuietThreadingHTTPServer((host, port), DashboardHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py deleted file mode 100644 index 73117f2..0000000 --- a/src/nine_rtksync/web/render.py +++ /dev/null @@ -1,820 +0,0 @@ -"""Renderização server-side do dashboard do 9RTKSync. - -Todo o HTML é montado aqui, no servidor, com os dados já embutidos. O navegador -nunca consulta o banco: ele recebe a página pronta. Isso mantém o SQLite -inteiramente do lado do servidor e faz o painel funcionar mesmo com JavaScript -desabilitado — o jQuery serve só para conforto. - -Ícones: Bootstrap Icons e flag-icons (fontes/CSS de ícones), nunca emoji. -Idioma padrão: inglês, com português e espanhol no seletor de bandeiras. -""" - -import html -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional - -from ..i18n import DEFAULT_LANGUAGE, LANGUAGES, normalize_language, translate - -BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" -BOOTSTRAP_ICONS = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" -FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" -BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" -JQUERY_JS = "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" -# Tipografia: Google Fonts, com pilha de sistema como reserva se o CDN cair. -GOOGLE_FONTS = ( - "https://fonts.googleapis.com/css2?" - "family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" -) -FONT_STACK = "'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" -MONO_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace" - -# Estado semântico -> (classe do badge, ícone) -HEALTH_PRESENTATION = { - "active": ("text-bg-success", "bi-check-circle-fill"), - "expiring_soon": ("text-bg-warning", "bi-hourglass-split"), - "expired": ("text-bg-danger", "bi-x-octagon-fill"), - "rate_limited": ("text-bg-warning", "bi-pause-circle-fill"), - "no_expiration": ("text-bg-secondary", "bi-infinity"), - "unknown": ("text-bg-secondary", "bi-question-circle-fill"), - # Estados vindos da validação viva da credencial. - "invalid": ("text-bg-danger", "bi-shield-exclamation"), - "unreachable": ("text-bg-warning", "bi-plug"), - "not_checked": ("text-bg-secondary", "bi-dash-circle"), -} - - -def esc(value: Any) -> str: - """Escapa qualquer valor para inserção segura no HTML.""" - return html.escape(str(value if value is not None else ""), quote=True) - - -def format_duration(seconds: Optional[int], lang: str = DEFAULT_LANGUAGE) -> str: - """Formata uma duração em segundos de forma legível.""" - if seconds is None: - return translate("duration.unlimited", lang) - if seconds <= 0: - return translate("duration.expired", lang) - if seconds < 60: - return f"{seconds}s" - minutes = seconds // 60 - if minutes < 60: - return f"{minutes} min" - hours = minutes // 60 - rest = minutes % 60 - if hours < 24: - return f"{hours}h {rest:02d}min" - days = hours // 24 - return f"{days}d {hours % 24}h" - - -def format_timestamp(value: Optional[str]) -> str: - """Normaliza um timestamp ISO para exibição.""" - if not value: - return "—" - return str(value).replace("T", " ").replace("Z", " UTC") - - -def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LANGUAGE) -> str: - """Explica, em uma frase, por que a conexão foi ou não renovada. - - Sem isso o painel mostra apenas "0 renovadas" e não há como distinguir - "nada precisava ser renovado" de "a renovação falhou". - """ - if conn.is_local: - # Quem diz se a instância respondeu é a sonda, não o tamanho do - # catálogo: uma instalação nova, de pé e sem nenhum modelo baixado, - # devolve lista vazia com HTTP 200. Contar modelos aqui a anunciava - # como inalcançável, contradizendo o "ativa" que o próprio ciclo - # acabara de gravar no banco. - if conn.data.get("testStatus") == "unreachable": - return translate("reason.local_unreachable", lang) - models = conn.local_models - if models: - return translate("reason.local_ok", lang, count=len(models)) - return translate("reason.local_empty", lang) - - if not conn.is_oauth: - return translate("reason.api_key", lang) - - remaining = conn.remaining_seconds - if remaining is None: - return translate("reason.no_expiry", lang) - if remaining <= 0: - return translate("reason.expired", lang) - - margin_min = max(1, refresh_margin // 60) - if remaining <= refresh_margin: - return translate("reason.inside_margin", lang, margin=margin_min) - return translate( - "reason.outside_margin", - lang, - margin=margin_min, - eta=format_duration(remaining - refresh_margin, lang), - ) - - -def render_last_refresh(conn: Any, lang: str) -> str: - """Mostra quando a credencial foi renovada pela ultima vez, e ha quanto tempo.""" - stamp = conn.last_refresh_at - if not stamp: - return f'{esc(translate("table.never_refreshed", lang))}' - - ago = "" - try: - moment = datetime.fromisoformat(str(stamp).replace("Z", "+00:00")) - if moment.tzinfo is None: - moment = moment.replace(tzinfo=timezone.utc) - elapsed = int((datetime.now(timezone.utc) - moment).total_seconds()) - if elapsed >= 0: - ago = translate("table.time_ago", lang, elapsed=format_duration(elapsed, lang)) - except (ValueError, TypeError): - # Carimbo de tempo em formato desconhecido vira "sem informacao" na - # tela. Uma data ilegivel nao pode derrubar a renderizacao da pagina. - pass - - icon = '' - detail = f'
    {esc(ago)}
    ' if ago else "" - return f'{icon}{esc(format_timestamp(stamp))}{detail}' - - -def render_remaining(conn: Any, lang: str) -> str: - """Validade restante, sem chamar de ilimitado o que so esta faltando. - - Um token OAuth sempre expira. Quando nao ha expiresAt legivel, isso e dado - ausente -- normalmente porque o gateway gravou a validade num formato que - nao soube reler -- e nao uma credencial eterna. So chave estatica pode ser - apresentada como sem expiracao. - """ - remaining = conn.remaining_seconds - if remaining is not None: - return esc(format_duration(remaining, lang)) - - if conn.is_oauth: - return ( - '' - '' - f'{esc(translate("duration.unknown_expiry", lang))}' - ) - return f'{esc(translate("duration.no_expiry", lang))}' - - -def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: - """Pagina autonoma para respostas fora do painel autenticado. - - E o que o navegador exibe quando o usuario aperta ESC no dialogo do Basic - Auth, entao nao pode conter nem credencial nem dica de credencial. - """ - link = ( - f'

    {esc(link_label)}

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

    {esc(title)}

    -

    {esc(body)}

    - {link} -
    -
    - -""".encode("utf-8") - - -def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: - """Marca de saída de rede da conexão, em modo somente leitura. - - O risco de bloqueio não vem de várias sessões na mesma conta -- isso os - provedores aceitam -- e sim de várias contas saindo pelo mesmo endereço. - Por isso "compartilhada" só vira aviso a partir da segunda conta nessa - situação: sozinha, ela é a única dona daquele IP. - """ - estado = conn.egress_status - 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))}' - ) - return ( - '' - f'' - f'{esc(translate("egress.single", lang))}' - ) - return ( - '' - f'' - f'{esc(translate("egress.unknown", lang))}' - ) - - -def health_badge(status: str, lang: str) -> str: - """Monta o badge de saúde com ícone de fonte.""" - css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["unknown"]) - label = translate(f"health.{status}", lang) - return ( - f'' - f'{esc(label)}' - ) - - -def render_language_switcher(current: str) -> str: - """Seletor de idioma com bandeiras reais (flag-icons), não emoji.""" - current = normalize_language(current) - _, current_flag = LANGUAGES[current] - items = [] - for code, (label, flag) in LANGUAGES.items(): - active = " active" if code == current else "" - items.append( - f'
  • ' - ) - return f""" - - - - """ - - -def metric_card(label: str, value: Any, icon: str, tone: str) -> str: - return f""" -
    -
    -
    -
    - {esc(label)} -
    -
    {esc(value)}
    -
    -
    -
    """ - - -def render_security_banner(is_default_password: bool, lang: str) -> str: - if not is_default_password: - return "" - return f""" - """ - - -def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: - if not connections: - return f""" -
    - - {esc(translate("connections.empty", lang))} -
    """ - - # Quantas contas de nuvem saem pelo endereço padrão do gateway. 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" - ) - - rows = [] - for c in connections: - if c.is_local: - kind, kind_icon = translate("type.local", lang), "bi-hdd-network" - elif c.is_oauth: - kind, kind_icon = translate("type.oauth", lang), "bi-person-badge" - elif c.has_api_key: - kind, kind_icon = translate("type.api_key", lang), "bi-key" - else: - kind, kind_icon = translate("type.local", lang), "bi-hdd-network" - - # Instancia local: mostra a origem e os modelos que ela realmente serve. - detail = "" - if c.is_local: - models = c.local_models - parts = [] - if c.base_url: - parts.append(f'{esc(c.base_url)}') - if models: - preview = ", ".join(models[:3]) + (f" (+{len(models) - 3})" if len(models) > 3 else "") - parts.append( - f'{len(models)} ' - f'{esc(translate("table.models", lang))} {esc(preview)}' - ) - if parts: - detail = f'
    {" · ".join(parts)}
    ' - else: - # 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}
    ' - - rows.append(f""" - - {esc(c.provider)} - {esc(c.name)}{detail} - - {esc(kind)} - - {health_badge(c.health_status, lang)} - {render_remaining(c, lang)} - {render_last_refresh(c, lang)} - {esc(render_refresh_reason(c, refresh_margin, lang))} - """) - - return f""" -
    - - - - - - - - - - - - - {"".join(rows)} - -
    {esc(translate("table.provider", lang))}{esc(translate("table.name", lang))}{esc(translate("table.type", lang))}{esc(translate("table.status", lang))}{esc(translate("table.remaining", lang))}{esc(translate("table.last_refresh", lang))}{esc(translate("table.diagnosis", lang))}
    -
    """ - - -def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: - if not combos: - return f""" -
    - - {esc(translate("combos.empty", lang))} -
    """ - - rows = [] - for combo in combos: - models = combo.get("models") or [] - if isinstance(models, str): - models = [models] - preview = ", ".join(str(m) for m in models[:4]) - if len(models) > 4: - preview += f" (+{len(models) - 4})" - rows.append(f""" - - {esc(combo.get("name", "—"))} - {esc(preview) or "—"} - """) - - return f""" -
    - - - - - - - - {"".join(rows)} - -
    {esc(translate("table.combo", lang))}{esc(translate("table.cascade", lang))}
    -
    """ - - -def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: - """Lista de execuções do cron, cada uma com o log do que realmente aconteceu.""" - if not history: - return f'

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

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

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

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

    - -

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

    - {esc(state_text)} -

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

    9RTKSync

    -

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

    -
    -
    -
    - {render_language_switcher(lang)} -
    - -
    - -
    - -
    -
    -
    - -
    {metrics} -
    - -
    -
    {render_gateway_card(gateway, db_path, lang)}
    -
    {render_cron_card(cron, lang)}
    -
    - -
    -
    - - {esc(translate("connections.title", lang))} - - {len(connections)} -
    - {render_connections_table(connections, refresh_margin, lang)} -
    - -
    -
    - {esc(translate("combos.title", lang))} -
    - {render_combos_table(combos, lang)} -
    - -
    - - {esc(translate("footer.signed_in", lang))} - {esc(current_user)} - - - {esc(translate("footer.generated", lang))} - {esc(generated_at)} - -
    -
    - - - - - - - - - -""" diff --git a/src/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py deleted file mode 100644 index a82256f..0000000 --- a/src/nine_rtksync/web/server.py +++ /dev/null @@ -1,737 +0,0 @@ -"""Multi-threaded HTTP server and embedded web dashboard for 9RTKSync.""" - -import base64 -import json -import os -import sys -import threading -import time -import urllib.error -import urllib.request -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer -from typing import Any, Callable, Dict, Optional - -from urllib.parse import parse_qs, urlparse - -from ..config import Settings -from ..i18n import DEFAULT_LANGUAGE, normalize_language, translate -from ..prefs import get_preference, resolve_prefs_path, set_preference -from ..database import get_all_combos, get_all_connections -from ..models import ConnectionRecord -from .render import render_dashboard, render_notice_page - -# Tempo de vida do resultado da sondagem ao gateway. O /healthz é chamado a cada -# 15s pelo Docker; sem cache, cada chamada faria uma requisição HTTP de saída de -# até 3s, atrasando a resposta além do timeout do probe. -ROUTER_PROBE_TTL_SECONDS = 30.0 - -# Erros de socket que significam apenas "o cliente desistiu antes de ler a -# resposta" — comportamento normal de health check, não falha do servidor. -CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) - -# Cache do resultado da sondagem ao gateway, compartilhado entre as threads do servidor. -_router_probe_cache: Dict[str, tuple] = {} -_router_probe_lock = threading.Lock() - - -class QuietThreadingHTTPServer(ThreadingHTTPServer): - """Servidor multi-thread que não polui o log quando o cliente desconecta antes da hora.""" - - daemon_threads = True - - def handle_error(self, request, client_address): - exc = sys.exc_info()[1] - if isinstance(exc, CLIENT_DISCONNECT_ERRORS): - return - super().handle_error(request, client_address) - - -class DashboardHandler(BaseHTTPRequestHandler): - """HTTP handler serving dashboard UI, REST API, and cron scheduler with Basic Auth.""" - - settings: Optional[Settings] = None - sync_trigger_callback: Optional[Callable[[], Dict[str, Any]]] = None - cron_scheduler: Optional[Any] = None - db_path: str = "" - router_url: str = "" - _last_gw_check: float = 0.0 - _last_gw_ok: bool = True - - def log_message(self, format, *args): - pass - - def check_auth(self) -> bool: - if not self.settings: - return True - - auth_header = self.headers.get("Authorization", "") - if not auth_header or not auth_header.startswith("Basic "): - return False - - try: - b64_val = auth_header[6:].strip() - decoded = base64.b64decode(b64_val).decode("utf-8") - if ":" not in decoded: - return False - user, pwd = decoded.split(":", 1) - # Delega ao Settings: credenciais salvas, padrão de fábrica e a - # credencial de recuperação (admin + hash) são avaliadas lá. - return self.settings.verify_credentials(user, pwd) - except Exception: - return False - - def require_auth(self) -> bool: - if self.check_auth(): - return True - - lang = self.resolve_language() - payload = render_notice_page( - translate("auth.required", lang), translate("auth.required_body", lang) - ) - self.send_response(HTTPStatus.UNAUTHORIZED) - self.send_header("WWW-Authenticate", 'Basic realm="9RTKSync Dashboard"') - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.write_body(payload) - return False - - # Politica de seguranca aplicada a TODAS as respostas, nao so a pagina - # principal: o 401, o aviso de credenciais trocadas e os redirects tambem - # sao HTML que o navegador renderiza. - SECURITY_HEADERS = ( - ("Referrer-Policy", "no-referrer"), - ("X-Content-Type-Options", "nosniff"), - ("X-Frame-Options", "DENY"), - ( - "Content-Security-Policy", - # Restrita ao que a pagina realmente carrega: Bootstrap e os icones - # vem do jsDelivr, as fontes do Google. connect-src 'self' porque o - # painel e inteiramente renderizado no servidor, entao um HTML - # injetado nao tem para onde exfiltrar. - "default-src 'self'; " - "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net " - "https://fonts.googleapis.com; " - "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com data:; " - # As bandeiras do seletor de idioma sao SVG que o CSS do - # flag-icons busca no mesmo CDN. Sem esta origem elas - # simplesmente nao aparecem, sem erro visivel na tela. - "img-src 'self' data: https://cdn.jsdelivr.net; " - "connect-src 'self'; " - "form-action 'self'; " - "frame-ancestors 'none'; " - "base-uri 'none'" - ), - ) - - def end_headers(self): - """Injeta os cabecalhos de seguranca antes de fechar o bloco.""" - enviados = {k.lower() for k, _ in self._headers_buffer_names()} - for nome, valor in self.SECURITY_HEADERS: - if nome.lower() not in enviados: - self.send_header(nome, valor) - super().end_headers() - - def _headers_buffer_names(self): - """Nomes ja enfileirados nesta resposta, para nao duplicar cabecalho.""" - for linha in getattr(self, "_headers_buffer", []) or []: - try: - texto = linha.decode("latin-1", "ignore") - except Exception: - continue - if ":" in texto: - yield texto.split(":", 1)[0].strip(), texto - - def do_GET(self): - if self.path == "/healthz": - self.serve_healthz() - return - - # Servida antes de require_auth de proposito: o navegador ainda esta - # com a senha antiga neste instante, e exigir autenticacao aqui daria - # um 401 cru exatamente depois de a troca ter dado certo. - if urlparse(self.path).path == "/credenciais-atualizadas": - self.serve_credentials_updated() - return - - if not self.require_auth(): - return - - route = urlparse(self.path) - if route.path in ("/", "/index.html"): - self.serve_dashboard(query=parse_qs(route.query)) - elif route.path == "/api/status": - self.serve_api_status() - elif route.path == "/api/cron-status": - self.serve_cron_status() - else: - self.send_error(HTTPStatus.NOT_FOUND, "Page not found") - - def is_same_origin_request(self) -> bool: - """Rejeita POST disparado por outro site. - - O Basic Auth e anexado automaticamente pelo navegador mesmo em um POST - vindo de outra origem, e um formulario urlencoded nao dispara preflight. - Sem esta checagem, uma pagina maliciosa aberta na mesma maquina poderia - trocar a senha do painel. - - A ordem importa: o Origin e a evidencia forte e e avaliado primeiro. - Checar Sec-Fetch-Site antes disso fazia um valor inesperado do navegador - recusar a requisicao mesmo com o Origin batendo com o Host. Nao se usa - Referer porque a propria pagina e servida com Referrer-Policy: no-referrer. - """ - host = self.headers.get("Host", "") - origin = self.headers.get("Origin", "") - - if origin and origin != "null": - # Comparacao pelo host declarado: mesma origem, requisicao legitima. - if urlparse(origin).netloc == host: - return True - # Origin presente e divergente e a unica prova positiva de ataque. - return False - - fetch_site = self.headers.get("Sec-Fetch-Site", "") - if fetch_site: - # "none" e a navegacao digitada na barra de enderecos. - return fetch_site in ("same-origin", "none") - - # Cliente que nao e navegador (curl, script): nao ha sessao a sequestrar. - return True - - def do_POST(self): - if not self.require_auth(): - return - - if not self.is_same_origin_request(): - # Devolve o usuario para o painel explicando o motivo, em vez de uma - # pagina de erro crua sem caminho de volta. - self.redirect_to_dashboard( - "danger", translate("security.cross_origin", self.resolve_language()) - ) - return - - length = int(self.headers.get("Content-Length", 0)) - raw_body = self.rfile.read(length) if length > 0 else b"{}" - - route = urlparse(self.path).path - - # Acoes do dashboard: executam e redirecionam de volta para a pagina - # renderizada (POST-Redirect-GET), sem JSON no navegador. - if route.startswith("/acoes/"): - self.handle_dashboard_action(route, raw_body) - return - - if route == "/api/sync": - self.handle_sync_request() - elif route == "/api/test-gateway": - self.handle_test_gateway() - elif route == "/api/change-password": - self.handle_change_password(raw_body) - elif route == "/api/cron-run": - self.handle_cron_run() - else: - self.send_error(HTTPStatus.NOT_FOUND, "Endpoint not found") - - def redirect_to_dashboard(self, tone: str, message: str) -> None: - """Redireciona para a pagina com uma mensagem de resultado.""" - from urllib.parse import urlencode - - query = urlencode({"aviso": message, "tom": tone}) - self.send_response(HTTPStatus.SEE_OTHER) - self.send_header("Location", f"/?{query}") - self.send_header("Content-Length", "0") - self.end_headers() - - def invalidate_caches(self) -> None: - """Descarta o que foi memorizado para que a proxima renderizacao releia tudo. - - Sem isto, o resultado da sondagem ao gateway continuaria valendo por ate - 30s e o painel exibiria um estado anterior a acao que o usuario acabou - de disparar. - """ - with _router_probe_lock: - _router_probe_cache.clear() - - def handle_dashboard_action(self, route: str, raw_body: bytes) -> None: - """Executa uma acao do painel e devolve o usuario para a pagina renderizada.""" - if route == "/acoes/atualizar": - # Recarga completa: zera os caches e volta para a pagina, que e - # montada de novo no servidor a partir do banco. - self.invalidate_caches() - self.redirect_to_dashboard("info", translate("action.refreshed", self.resolve_language())) - return - - if route == "/acoes/sincronizar": - if not type(self).sync_trigger_callback: - self.redirect_to_dashboard("warning", "Sincronizacao manual indisponivel nesta instancia.") - return - try: - res = type(self).sync_trigger_callback() or {} - # A sincronizacao muda o estado do gateway: o cache anterior - # deixaria a tela mostrando o mundo de antes da acao. - self.invalidate_caches() - self.redirect_to_dashboard( - "success", - f"Sincronizacao concluida: {res.get('total_connections', 0)} conexoes inspecionadas, " - f"{res.get('normalized', 0)} normalizadas, {res.get('refreshed', 0)} renovadas.", - ) - except Exception as e: - self.redirect_to_dashboard("danger", f"Falha na sincronizacao: {e}") - return - - if route == "/acoes/cron": - if not self.cron_scheduler: - self.redirect_to_dashboard("warning", "Agendador nao esta ativo nesta instancia.") - return - try: - entry = self.cron_scheduler.trigger_now() or {} - self.redirect_to_dashboard( - "success", - f"Ciclo executado em {entry.get('durationMs', 0)}ms: " - f"{entry.get('totalInspected', 0)} avaliadas, {entry.get('refreshedCount', 0)} renovadas.", - ) - except Exception as e: - self.redirect_to_dashboard("danger", f"Falha ao executar o ciclo: {e}") - return - - if route == "/acoes/testar-gateway": - # Invalida o cache para forcar uma sondagem real nesta acao explicita. - with _router_probe_lock: - _router_probe_cache.pop(self.router_url, None) - online = self.probe_router() - self.redirect_to_dashboard( - "success" if online else "danger", - "Gateway respondeu normalmente." if online else "Gateway nao respondeu.", - ) - return - - if route == "/acoes/idioma": - fields = parse_qs(raw_body.decode("utf-8", errors="replace")) - chosen = normalize_language((fields.get("lang", [""])[0] or "").strip()) - set_preference(self.prefs_path(), "language", chosen) - self.send_response(HTTPStatus.SEE_OTHER) - self.send_header("Location", "/") - self.send_header("Content-Length", "0") - self.end_headers() - return - - if route == "/acoes/credenciais": - fields = parse_qs(raw_body.decode("utf-8", errors="replace")) - new_user = (fields.get("user", [""])[0] or "").strip() - new_pass = (fields.get("password", [""])[0] or "").strip() - - # A politica de forca e obrigatoria: devolve todas as regras - # violadas de uma vez, no idioma escolhido, em vez de recusar sem - # dizer o motivo. - problems = self.settings.check_password_strength(new_pass) if self.settings else [] - if problems: - lang = self.resolve_language() - self.redirect_to_dashboard( - "danger", " ".join(translate(key, lang) for key in problems) - ) - return - if self.settings and getattr(self.settings, "dashboard_auth_from_env", False): - self.redirect_to_dashboard( - "warning", - "Credenciais definidas por variavel de ambiente. Altere-as no ambiente e reinicie.", - ) - return - if self.settings and self.settings.update_auth_credentials(new_user, new_pass): - self.send_response(HTTPStatus.SEE_OTHER) - self.send_header("Location", "/credenciais-atualizadas") - self.send_header("Content-Length", "0") - self.end_headers() - return - self.redirect_to_dashboard("danger", "Nao foi possivel salvar as credenciais.") - return - - self.send_error(HTTPStatus.NOT_FOUND, "Acao nao encontrada") - - def probe_router(self) -> bool: - """Sonda o gateway com cache: o resultado vale por ROUTER_PROBE_TTL_SECONDS.""" - if not self.router_url: - return True - - now = time.time() - with _router_probe_lock: - cached_at, cached_ok = _router_probe_cache.get(self.router_url, (0.0, None)) - if cached_ok is not None and (now - cached_at) < ROUTER_PROBE_TTL_SECONDS: - return cached_ok - - try: - req = urllib.request.Request( - self.router_url, - headers={"User-Agent": "9RTKSync-Healthcheck/1.0"}, - ) - with urllib.request.urlopen(req, timeout=3.0) as resp: - router_ok = resp.status < 500 - except urllib.error.HTTPError as e: - router_ok = e.code < 500 - except Exception: - router_ok = False - - with _router_probe_lock: - _router_probe_cache[self.router_url] = (time.time(), router_ok) - return router_ok - - def write_body(self, payload: bytes) -> None: - """Escreve o corpo tolerando o cliente ter fechado a conexão antes da leitura.""" - try: - self.wfile.write(payload) - except CLIENT_DISCONNECT_ERRORS: - self.close_connection = True - - def serve_credentials_updated(self): - """Confirma a troca de senha sem exigir a credencial que acabou de mudar.""" - lang = self.resolve_language() - payload = render_notice_page( - translate("auth.updated_title", lang), - translate("auth.updated_body", lang), - translate("auth.updated_link", lang), - ) - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.write_body(payload) - - def serve_healthz(self): - """Health check do Docker: barato, sem cache do navegador e sem excecao no log. - - A sondagem ao gateway passa por probe_router, que memoriza o resultado; - sem isso cada probe pagava ate 3s de HTTP de saida e estourava o timeout - do healthcheck, que fechava o socket e gerava BrokenPipeError. - """ - db_ok = bool(self.db_path and os.path.exists(self.db_path)) - router_ok = self.probe_router() - - if db_ok and router_ok: - status, payload = HTTPStatus.OK, b"OK" - elif not db_ok: - status, payload = HTTPStatus.SERVICE_UNAVAILABLE, b"DATABASE_NOT_READY" - else: - status, payload = HTTPStatus.SERVICE_UNAVAILABLE, b"ROUTER_SERVICE_UNREACHABLE" - - self.send_response(status) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.write_body(payload) - - def prefs_path(self) -> str: - """Banco de preferencias proprio do sincronizador (nunca o do gateway).""" - base = os.path.dirname(self.settings.get_auth_file_path()) if self.settings else "" - return resolve_prefs_path(base or os.path.expanduser("~")) - - def resolve_language(self) -> str: - """Idioma em vigor: preferencia salva no SQLite, senao o padrao (ingles).""" - return normalize_language(get_preference(self.prefs_path(), "language", DEFAULT_LANGUAGE)) - - def collect_dashboard_state(self) -> Dict[str, Any]: - """Le tudo o que a pagina precisa. Roda no servidor: o SQLite nunca sai daqui.""" - conns: list = [] - combos: list = [] - db_exists = bool(self.db_path and os.path.exists(self.db_path)) - if db_exists: - try: - conns = get_all_connections(self.db_path) - except Exception: - conns = [] - try: - combos = get_all_combos(self.db_path) - except Exception: - combos = [] - - start_t = time.time() - online = self.probe_router() - latency_ms = int((time.time() - start_t) * 1000) - - return { - "connections": conns, - "combos": combos, - "cron": self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False}, - "gateway": { - "url": self.router_url, - "online": online, - "statusCode": 200 if online else 0, - "latencyMs": latency_ms, - # Bandeira explicita: o resumo e texto para humano e vinha - # sempre preenchido, inclusive com "Banco nao encontrado". - # Converter esse texto em booleano fazia a tela declarar banco e - # gateway 100% operacionais justamente quando o arquivo sumia. - "dbOk": db_exists, - "dbSummary": ( - f"Operacional ({len(conns)} conexoes, {len(combos)} combos)" - if db_exists - else "Banco nao encontrado" - ), - }, - } - - def serve_dashboard(self, query: Optional[Dict[str, list]] = None): - """Renderiza a pagina inteira no servidor, com os dados ja embutidos.""" - query = query or {} - state = self.collect_dashboard_state() - - flash = None - aviso = (query.get("aviso") or [""])[0] - if aviso: - flash = {"message": aviso, "tone": (query.get("tom") or ["info"])[0]} - - current_user = "admin" - is_default = False - auth_from_env = False - refresh_margin = 900 - if self.settings: - current_user, _ = self.settings.get_auth_credentials() - is_default = self.settings.is_default_password() - auth_from_env = getattr(self.settings, "dashboard_auth_from_env", False) - refresh_margin = self.settings.refresh_margin - - content = render_dashboard( - connections=state["connections"], - combos=state["combos"], - cron=state["cron"], - gateway=state["gateway"], - db_path=self.db_path, - router_url=self.router_url, - current_user=current_user, - is_default_password=is_default, - refresh_margin=refresh_margin, - auth_from_env=auth_from_env, - flash=flash, - lang=self.resolve_language(), - ).encode("utf-8") - - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - # A pagina carrega dados vivos: nunca pode vir do cache do navegador. - self.send_header("Cache-Control", "no-store, must-revalidate") - self.send_header("Content-Length", str(len(content))) - self.end_headers() - self.write_body(content) - - def serve_api_status(self): - conns = [] - combos = [] - if self.db_path and os.path.exists(self.db_path): - try: - conns = get_all_connections(self.db_path) - except Exception: - conns = [] - try: - combos = get_all_combos(self.db_path) - except Exception: - combos = [] - - cron_info = self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False} - is_default = self.settings.is_default_password() if self.settings else False - cur_user, _ = self.settings.get_auth_credentials() if self.settings else ("admin", "") - - payload = { - "status": "online", - "routerUrl": self.router_url, - "dbPath": self.db_path, - "currentUser": cur_user, - "isDefaultPassword": is_default, - "cron": cron_info, - "connections": [ - { - "id": c.id, - "provider": c.provider, - "name": c.name, - "isOAuth": c.is_oauth, - "hasApiKey": c.has_api_key, - "expiresAtMs": c.expires_at_ms, - "remainingSeconds": c.remaining_seconds, - "healthStatus": c.health_status, - "updatedAt": c.updated_at, - } - for c in conns - ], - "combos": combos, - } - body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - - def serve_cron_status(self): - cron_info = self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False} - body = json.dumps(cron_info, ensure_ascii=False, indent=2).encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.write_body(body) - - def handle_test_gateway(self): - start_t = time.time() - gateway_ok = False - status_code = 0 - gateway_err = "" - router_target = self.router_url - - try: - req = urllib.request.Request( - router_target, - headers={"User-Agent": "9RTKSync-Tester/1.0"}, - ) - with urllib.request.urlopen(req, timeout=5.0) as resp: - status_code = resp.status - gateway_ok = status_code < 500 - except urllib.error.HTTPError as e: - status_code = e.code - gateway_ok = e.code < 500 - except Exception as ex: - gateway_err = str(ex) - - latency_ms = int((time.time() - start_t) * 1000) - - db_exists = bool(self.db_path and os.path.exists(self.db_path)) - conns_count = 0 - combos_count = 0 - if db_exists: - try: - conns = get_all_connections(self.db_path) - combos = get_all_combos(self.db_path) - conns_count = len(conns) - combos_count = len(combos) - except Exception: - pass - - result = { - "success": gateway_ok and db_exists, - "gatewayUrl": router_target, - "gatewayStatus": "online" if gateway_ok else "offline", - "httpStatusCode": status_code, - "latencyMs": latency_ms, - "gatewayError": gateway_err if not gateway_ok else None, - "dbStatus": "ok" if db_exists else "not_found", - "dbPath": self.db_path, - "connectionsCount": conns_count, - "combosCount": combos_count, - "message": "9Router gateway and SQLite database are 100% operational!" if (gateway_ok and db_exists) else "Failed to connect to 9Router or database unavailable", - } - - body = json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - - def handle_change_password(self, raw_body: bytes): - try: - data = json.loads(raw_body.decode("utf-8")) if raw_body else {} - - new_user = str(data.get("newUser") or "admin").strip() - new_pass = str(data.get("newPassword") or "").strip() - - # Mesma politica de forca do formulario da tela. - problems = self.settings.check_password_strength(new_pass) if self.settings else [] - if problems: - lang = self.resolve_language() - detail = " ".join(translate(key, lang) for key in problems) - body = json.dumps({"success": False, "error": detail}).encode("utf-8") - self.send_response(HTTPStatus.BAD_REQUEST) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - return - - if self.settings: - ok = self.settings.update_auth_credentials(new_user, new_pass) - if ok: - body = json.dumps({ - "success": True, - "message": "Credentials updated successfully! Use your new username and password for future requests.", - "newUser": new_user, - }).encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - return - - self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not save credentials") - except Exception as e: - body = json.dumps({"success": False, "error": str(e)}).encode("utf-8") - self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - - def handle_sync_request(self): - if DashboardHandler.sync_trigger_callback: - try: - res = DashboardHandler.sync_trigger_callback() - body = json.dumps({"success": True, "result": res}).encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - return - except Exception as e: - err = json.dumps({"success": False, "error": str(e)}).encode("utf-8") - self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(err))) - self.end_headers() - self.write_body(err) - return - self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, "Synchronizer unavailable") - - def handle_cron_run(self): - if DashboardHandler.cron_scheduler: - try: - entry = DashboardHandler.cron_scheduler.trigger_now() - body = json.dumps({"success": True, "cycle": entry}).encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.write_body(body) - return - except Exception as e: - err = json.dumps({"success": False, "error": str(e)}).encode("utf-8") - self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(err))) - self.end_headers() - self.write_body(err) - return - self.handle_sync_request() - - -def start_web_server( - host: str, - port: int, - db_path: str, - router_url: str = "", - sync_callback: Optional[Callable[[], Dict[str, Any]]] = None, - settings: Optional[Settings] = None, - cron_scheduler: Optional[Any] = None, - ) -> ThreadingHTTPServer: - """Start HTTP server on background thread with Basic Auth and Cron Scheduler.""" - DashboardHandler.db_path = db_path - DashboardHandler.router_url = router_url - DashboardHandler.sync_trigger_callback = sync_callback - DashboardHandler.settings = settings - DashboardHandler.cron_scheduler = cron_scheduler - - server = QuietThreadingHTTPServer((host, port), DashboardHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return server - diff --git a/tests/test_ajuda_nao_publica_credencial.py b/tests/test_ajuda_nao_publica_credencial.py new file mode 100644 index 0000000..383746c --- /dev/null +++ b/tests/test_ajuda_nao_publica_credencial.py @@ -0,0 +1,121 @@ +"""O texto de --help não pode anunciar credencial de fábrica. + +O repositório já garantia que a tela não mostra "admin / pathbit" +(test_web_security), mas o `--help` ficou de fora da guarda e continuava +imprimindo "(padrão: pathbit)" -- em arquivo versionado, numa linha que todo +operador lê antes de subir o serviço. Uma senha de fábrica anunciada vira a +senha real de toda instalação que copiou e colou. + +Agravante do caso original: o valor também era falso. O padrão real é string +vazia, e o painel gera uma credencial de recuperação no primeiro boot. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +FONTE = RAIZ / "src" / "nine_rtksync" + +# Senhas de fábrica conhecidas dos gateways que este projeto acompanha. A senha +# REAL da instalação não entra aqui: ela é lida do .env em tempo de execução, +# logo abaixo. A primeira versão deste arquivo trazia o valor real escrito à +# mão -- um teste criado para impedir credencial em arquivo versionado +# carregando uma, que é o tipo de ironia que passa despercebida por meses. +CREDENCIAIS_DE_FABRICA = ("pathbit", "123456", "changeme", "admin123") + + +def senhas_reais_desta_instalacao(): + """Lê do .env (gitignored) o que NUNCA pode aparecer em arquivo versionado. + + Mais forte do que uma lista fixa: protege a senha que o operador escolheu, + qualquer que seja ela, e não só as que alguém lembrou de escrever aqui. + """ + env = RAIZ / ".env" + if not env.exists(): + return set() + achadas = set() + for linha in env.read_text(encoding="utf-8", errors="ignore").splitlines(): + linha = linha.strip() + if not linha or linha.startswith("#") or "=" not in linha: + continue + chave, _, valor = linha.partition("=") + valor = valor.strip().strip('"').strip("'") + # Só o que é credencial, e só se for específico o bastante para uma + # busca não dar falso positivo em texto comum. + if len(valor) >= 8 and any( + marca in chave.upper() + for marca in ("PASSWORD", "SECRET", "TOKEN", "KEY", "PASSWD") + ): + achadas.add(valor) + return achadas + + +class AjudaNaoPublicaCredencial(unittest.TestCase): + def test_nenhum_texto_de_ajuda_cita_credencial_de_fabrica(self): + achados = [] + for arquivo in sorted(FONTE.rglob("*.py")): + for numero, linha in enumerate(arquivo.read_text(encoding="utf-8").splitlines(), 1): + if "help=" not in linha and "add_argument" not in linha: + continue + for valor in CREDENCIAIS_DE_FABRICA: + if re.search(rf"\b{re.escape(valor)}\b", linha, re.IGNORECASE): + achados.append(f"{arquivo.relative_to(RAIZ)}:{numero}: {linha.strip()[:90]}") + self.assertEqual( + achados, + [], + "texto de ajuda anunciando credencial:\n " + "\n ".join(achados), + ) + + def test_a_senha_padrao_do_codigo_continua_vazia(self): + """Se alguém reintroduzir um padrão, o texto de ajuda volta a mentir.""" + config = (FONTE / "config.py").read_text(encoding="utf-8") + self.assertRegex( + config, + r'dashboard_password:\s*str\s*=\s*""', + "o padrão tem de ser vazio: qualquer valor aqui é credencial de fábrica", + ) + + + def test_nenhuma_senha_real_aparece_em_arquivo_versionado(self): + """A senha escolhida pelo operador não pode estar em lugar nenhum do repo. + + Varre o que o git rastreia -- não o disco -- porque é o que sai daqui + quando alguém clona ou publica. + """ + import subprocess + + senhas = senhas_reais_desta_instalacao() + if not senhas: + self.skipTest("sem .env nesta máquina: nada a comparar") + + try: + saida = subprocess.run( + ["git", "-C", str(RAIZ), "ls-files"], + capture_output=True, + text=True, + check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + # O contêiner de teste do LiteLlm não traz git. Sem a lista do que + # é rastreado, a varredura mediria o disco -- onde o .env mora de + # propósito -- e acusaria justamente o arquivo que deve conter a + # senha. Pular é mais honesto do que medir a coisa errada. + self.skipTest("git indisponível: não dá para saber o que é versionado") + rastreados = saida.stdout.split() + + achados = [] + for relativo in rastreados: + caminho = RAIZ / relativo + try: + texto = caminho.read_text(encoding="utf-8", errors="ignore") + except (OSError, UnicodeDecodeError): + continue + for senha in senhas: + if senha in texto: + achados.append(f"{relativo}: contém uma credencial do .env") + self.assertEqual(achados, [], "credencial real em arquivo versionado:\n " + "\n ".join(achados)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cabecalho_server.py b/tests/test_cabecalho_server.py new file mode 100644 index 0000000..0b652dc --- /dev/null +++ b/tests/test_cabecalho_server.py @@ -0,0 +1,60 @@ +"""O cabeçalho Server não pode anunciar a pilha que serve o painel. + +`BaseHTTPRequestHandler` responde, por padrão, `Server: BaseHTTP/0.6 +Python/3.14.7` — a versão exata do interpretador, na primeira linha de TODA +resposta, inclusive no 401 que sai antes de qualquer autenticação. Ela viajava +logo acima da CSP, do `X-Frame-Options: DENY` e do `nosniff` que o resto do +cabeçalho instala: a mesma resposta que fecha as portas dizia qual é a +fechadura. + +Versão exata é o que um scanner precisa para escolher o exploit certo, e nada +no produto depende de publicá-la. +""" + +import pathlib +import sys +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RAIZ / "src")) + +from nine_rtksync.web import DashboardHandler # noqa: E402 + +HANDLER = RAIZ / "src" / "nine_rtksync" / "web.py" + +PROIBIDO = ("python", "basehttp", "simplehttp", "wsgi") + + +class CabecalhoServerNaoDenuncia(unittest.TestCase): + def test_o_handler_declara_nome_proprio_e_versao_vazia(self): + self.assertTrue( + DashboardHandler.server_version, + "sem server_version o padrão do BaseHTTP anuncia a versão do Python", + ) + self.assertEqual( + DashboardHandler.sys_version, + "", + "sys_version tem de ser vazio: é ele que carrega 'Python/3.x.y'", + ) + + def test_version_string_devolve_so_o_nome(self): + """Sem sobrescrever, o valor sai com um espaço sobrando no fim.""" + fonte = HANDLER.read_text(encoding="utf-8") + self.assertIn( + "def version_string", + fonte, + "BaseHTTPRequestHandler concatena server_version + ' ' + sys_version", + ) + + def test_o_nome_anunciado_nao_cita_a_pilha(self): + anunciado = DashboardHandler.server_version + for proibido in PROIBIDO: + self.assertNotIn( + proibido, + anunciado.lower(), + f"o nome anunciado ({anunciado!r}) entrega a pilha", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_carimbo_de_tempo.py b/tests/test_carimbo_de_tempo.py new file mode 100644 index 0000000..728e0fb --- /dev/null +++ b/tests/test_carimbo_de_tempo.py @@ -0,0 +1,39 @@ +"""Formatar um carimbo de tempo duas vezes tem de dar o mesmo resultado. + +`format_timestamp` trocava TODO "T" da string por espaço. Aplicada uma vez, +acertava: `2026-09-13T19:08:48Z` virava `2026-09-13 19:08:48 UTC`. Aplicada +sobre o próprio resultado — o que acontece quando um valor já formatado passa +por ela de novo, e isso é fácil de acontecer sem ninguém notar — ela comia o +"T" de "UTC" e a tela exibia `19:08:48 U C`. + +Foi assim que apareceu no painel, e o defeito parecia de CSS: dava para jurar +que a coluna era estreita e a palavra estava quebrando. +""" + +import unittest + +from nine_rtksync.render import format_timestamp + + +class CarimboDeTempo(unittest.TestCase): + def test_converte_o_formato_iso(self): + self.assertEqual(format_timestamp("2026-09-13T19:08:48Z"), "2026-09-13 19:08:48 UTC") + + def test_formatar_duas_vezes_nao_estraga(self): + uma = format_timestamp("2026-09-13T19:08:48Z") + self.assertEqual(format_timestamp(uma), uma, "UTC não pode virar 'U C'") + + def test_nao_come_o_t_de_outras_palavras(self): + """Só o separador da posição 10 é trocado, não todo T da string.""" + self.assertIn("UTC", format_timestamp("2026-09-13T19:08:48Z")) + + def test_valor_vazio_nao_derruba(self): + for entrada in (None, "", "—"): + self.assertIsInstance(format_timestamp(entrada), str) + + def test_valor_sem_z_continua_legivel(self): + self.assertEqual(format_timestamp("2026-09-13T19:08:48"), "2026-09-13 19:08:48") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cartoes_do_painel.py b/tests/test_cartoes_do_painel.py new file mode 100644 index 0000000..84021dc --- /dev/null +++ b/tests/test_cartoes_do_painel.py @@ -0,0 +1,219 @@ +"""Os seis cartões existem, sempre, e na mesma ordem dos painéis irmãos. + +A regra do dono é uma só: "as telas têm cards diferentes entre LiteLlmRTKSync, +OminiRTkSync e etc, tem que ter todos os cards iguais". O que pode mudar é o +CONTEÚDO; a existência e a ordem, não. + +Isto já quebrou uma vez em silêncio: este painel listava conexões e combos e não +tinha "chaves virtuais" nem "modelos cadastrados", enquanto o LiteLlmRTKSync +tinha os dois e nenhum dos outros. Ninguém viu no diff — cada repositório, +sozinho, parecia coerente. Quem descobriu foi quem abriu as três telas lado a +lado. + +Quando o gateway não tem o dado, o cartão continua na tela com o estado vazio +explicando por quê. É por isso que a guarda confere o cartão VAZIO também: a +tentação, ao portar, é esconder o que não tem dado. +""" + +import unittest + +from nine_rtksync.gateway import build_registered_models, fetch_gateway_models +from nine_rtksync.i18n import LANGUAGES, translate +from nine_rtksync.models import ConnectionRecord, VirtualKeyRecord +from nine_rtksync import render + +# A ordem acordada para os três painéis. A chave é o título traduzido de cada +# cartão, porque é isso que o operador vê. +ORDEM_DOS_CARTOES = ( + "gateway.title", # 1. Conexão com o gateway + "cron.title", # 2. Agendador + "connections.title", # 3. Conexões monitoradas + "keys.title", # 4. Chaves virtuais + "models.title", # 5. Modelos cadastrados + "combos.title", # 6. Combos de resiliência +) + + +def cabecalho_do_cartao(chave, lang): + """O título como ele aparece no CABEÇALHO do cartão, e não em qualquer lugar. + + Procurar só pelo texto traduzido acusa o lugar errado: "Chaves de API" é + também o rótulo de um cartão de métrica lá no topo, que vem antes de todos + os seis e faria a ordem parecer trocada. O que identifica o cabeçalho é o + ícone imediatamente antes do texto. + """ + return f'aria-hidden="true">{translate(chave, lang)}' + + +def pagina(lang="pt", **extra): + base = dict( + connections=[], + combos=[], + cron={"active": False}, + gateway={"url": "http://9rtk-router:20128", "online": True, "latencyMs": 3}, + db_path="/app/data/db/data.sqlite", + router_url="http://9rtk-router:20128", + current_user="admin", + is_default_password=False, + refresh_margin=900, + lang=lang, + ) + base.update(extra) + return render.render_dashboard(**base) + + +class OsSeisCartoes(unittest.TestCase): + def test_todos_os_seis_aparecem_em_qualquer_idioma(self): + for idioma in LANGUAGES: + html = pagina(idioma) + for chave in ORDEM_DOS_CARTOES: + with self.subTest(idioma=idioma, cartao=chave): + self.assertIn(cabecalho_do_cartao(chave, idioma), html) + + def test_a_ordem_na_pagina_e_a_ordem_acordada(self): + html = pagina("pt") + posicoes = [html.find(cabecalho_do_cartao(c, "pt")) for c in ORDEM_DOS_CARTOES] + self.assertNotIn(-1, posicoes, "algum cartão não foi desenhado") + self.assertEqual( + posicoes, + sorted(posicoes), + "a ordem dos cartões saiu trocada: " + + ", ".join(f"{c}@{p}" for c, p in zip(ORDEM_DOS_CARTOES, posicoes)), + ) + + def test_sem_dado_nenhum_o_cartao_fica_vazio_em_vez_de_sumir(self): + """Estado vazio honesto é melhor que assimetria — e o vazio diz por quê.""" + html = pagina("pt") + self.assertIn(translate("keys.title", "pt"), html) + self.assertIn(translate("keys.empty", "pt"), html) + self.assertIn(translate("models.title", "pt"), html) + self.assertIn(translate("models.empty", "pt"), html) + + def test_o_vazio_do_catalogo_diz_qual_dos_tres_motivos_foi(self): + """"Nenhum modelo" e "não deu para perguntar" não podem desenhar igual.""" + sem_chave = pagina("pt", models_state="no_key") + self.assertIn(translate("models.no_key", "pt"), sem_chave) + + mudo = pagina("pt", models_state="unreachable") + self.assertIn(translate("models.unreachable", "pt"), mudo) + + +class ChavesVirtuais(unittest.TestCase): + def chave(self, **campos): + base = {"id": "uuid-1", "name": "litellm-bridge", "machineId": "f19dff0d", + "isActive": True, "createdAt": "2026-09-13T20:21:07.765Z"} + base.update(campos) + return VirtualKeyRecord.from_row(base) + + def test_a_tabela_tem_as_mesmas_sete_colunas_dos_irmaos(self): + html = render.render_keys_table([self.chave()], "pt") + self.assertEqual(html.count("::`). +Lendo o campo cru do banco, o sincronizador mandava o texto cifrado para o +provedor, levava a recusa esperada e concluía "inválida" -- gravando isso de +volta no banco do gateway, que passava a exibir em vermelho uma conta que +ninguém chegou a testar. Não saber ler é um estado diferente de saber que está +ruim, e só um dos dois justifica mandar o usuário reautenticar. +""" + +import unittest + +from nine_rtksync.credential_check import ( + STATE_INVALID, + STATE_UNSUPPORTED, + check_connection, + looks_encrypted, +) + +CIFRADO = "enc:v1:00112233445566778899aabbccddeeff:00:00112233445566778899aabbccddeeff" + + +class ConexaoFalsa: + def __init__(self, **campos): + self.provider = "gemini" + self.is_local = False + self.is_oauth = False + self.has_api_key = False + self.access_token = None + self.api_key = None + self.base_url = None + for nome, valor in campos.items(): + setattr(self, nome, valor) + + +class CredencialCifrada(unittest.TestCase): + def test_reconhece_o_texto_cifrado(self): + self.assertTrue(looks_encrypted(CIFRADO)) + self.assertFalse(looks_encrypted("gsk_uma_chave_em_claro")) + self.assertFalse(looks_encrypted(None)) + + def test_token_cifrado_nao_vira_invalido(self): + r = check_connection(ConexaoFalsa(is_oauth=True, access_token=CIFRADO)) + self.assertEqual(r.state, STATE_UNSUPPORTED) + self.assertNotEqual(r.state, STATE_INVALID) + self.assertIn("encrypted at rest", r.detail) + + def test_chave_de_api_cifrada_nao_vira_invalida(self): + r = check_connection(ConexaoFalsa(has_api_key=True, api_key=CIFRADO)) + self.assertEqual(r.state, STATE_UNSUPPORTED) + self.assertIn("encrypted at rest", r.detail) + + def test_nao_faz_requisicao_nenhuma_com_valor_cifrado(self): + """A sonda não pode sequer sair: mandar o cifrado é o que sujava o painel.""" + chamou = [] + + def abridor(*a, **k): + chamou.append(a) + raise AssertionError("não deveria ter feito requisição") + + check_connection(ConexaoFalsa(is_oauth=True, access_token=CIFRADO), opener=abridor) + check_connection(ConexaoFalsa(has_api_key=True, api_key=CIFRADO), opener=abridor) + self.assertEqual(chamou, []) + + def test_credencial_em_claro_continua_sendo_sondada(self): + """A guarda não pode calar a validação de quem está legível.""" + saiu = [] + + def abridor(req, timeout=None): + saiu.append(getattr(req, "full_url", str(req))) + raise RuntimeError("corta aqui: o que importa é que a sonda saiu") + + check_connection(ConexaoFalsa(has_api_key=True, api_key="gsk_em_claro"), opener=abridor) + self.assertEqual(len(saiu), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_egress_panel.py b/tests/test_egress_panel.py index 078d1e6..e036a13 100644 --- a/tests/test_egress_panel.py +++ b/tests/test_egress_panel.py @@ -17,7 +17,7 @@ import unittest from nine_rtksync.models import ConnectionRecord -from nine_rtksync.web import render +from nine_rtksync import render def conexao(nome: str, **dados) -> ConnectionRecord: @@ -55,13 +55,18 @@ def test_a_bound_account_shows_its_own_pool(self): def test_two_accounts_on_the_gateway_address_raise_a_warning(self): h = self.html([compartilhada("Conta A"), compartilhada("Conta B")]) self.assertIn("divide o endereço do gateway", h) - self.assertIn("text-bg-warning-subtle", h, "o estado que importa precisa se destacar") + # bg-warning-subtle, e nao text-bg-warning-subtle: a segunda NAO existe + # no Bootstrap 5.3.3 (so ha text-bg-warning, sem o sufixo), entao o + # badge renderizava sem fundo nenhum -- texto solto onde devia haver + # destaque. O teste travava a classe inexistente e por isso o defeito + # atravessou verde. + self.assertIn("bg-warning-subtle", h, "o estado que importa precisa se destacar") def test_a_single_account_sharing_is_not_a_warning(self): # Uma conta sozinha e a unica dona daquele IP: nao ha nada a alertar. h = self.html([compartilhada("Conta unica")]) self.assertIn("única conta", h) - self.assertNotIn("text-bg-warning-subtle", h) + self.assertNotIn("bg-warning-subtle", h) def test_a_bound_account_does_not_count_toward_the_warning(self): # Duas contas, mas so uma compartilha: ainda nao ha duas no mesmo IP. diff --git a/tests/test_env_documentation.py b/tests/test_env_documentation.py index 0de0571..6e030a5 100644 --- a/tests/test_env_documentation.py +++ b/tests/test_env_documentation.py @@ -23,6 +23,12 @@ # container do 9Router -- nao por este programa. DO_GATEWAY = {"INITIAL_PASSWORD", "JWT_SECRET", "REQUIRE_API_KEY", "REQUIRE_LOGIN"} +# Lidas pelo COMPOSE, não pelo código Python: alimentam os serviços opcionais de +# acesso remoto (perfis `tunel` e `tailnet`). Precisam estar anunciadas no +# exemplo -- é lá que o operador descobre que existem -- mas nenhum os.environ +# daqui as procura, e é isso que a varredura acima mede. +DO_COMPOSE = {"TUNNEL_TOKEN", "TS_AUTHKEY"} + LEITURA = re.compile(r'os\.(?:environ\.get|getenv)\(\s*["\']([A-Z0-9_]+)["\']') INDICE = re.compile(r'os\.environ\[\s*["\']([A-Z0-9_]+)["\']') DECLARACAO = re.compile(r'^#?\s*([A-Z0-9_]+)=', re.M) @@ -53,7 +59,7 @@ def test_every_variable_the_code_reads_is_documented(self): ) def test_the_example_documents_nothing_the_code_ignores(self): - sobrando = sorted(variaveis_documentadas() - variaveis_lidas() - DO_GATEWAY) + sobrando = sorted(variaveis_documentadas() - variaveis_lidas() - DO_GATEWAY - DO_COMPOSE) self.assertEqual( sobrando, [], "variaveis no .env.example que programa nenhum le: " + ", ".join(sobrando), diff --git a/tests/test_espera_pelo_gateway.py b/tests/test_espera_pelo_gateway.py new file mode 100644 index 0000000..93a7170 --- /dev/null +++ b/tests/test_espera_pelo_gateway.py @@ -0,0 +1,51 @@ +"""Gateway recém-subido não é falha. + +O gateway cria o banco quando é usado pela primeira vez. Entre subir a stack e +cadastrar a primeira conexão, o arquivo simplesmente não existe — e isso é o +estado normal de quem acabou de instalar, não um erro. + +Relatar como falha pintava o painel de vermelho no primeiro minuto de uso, com +um `ERRO: db_not_found` no histórico do agendador. O efeito colateral é pior +que o susto: ensina o operador a ignorar o indicador de erro, que é justamente +o que precisa continuar significando alguma coisa quando algo quebrar de +verdade. +""" + +import os +import tempfile +import unittest + +from nine_rtksync.daemon import SyncEngine +from nine_rtksync.config import Settings + + +class TestBancoAusenteNaoEFalha(unittest.TestCase): + def ciclo_sem_banco(self): + d = tempfile.mkdtemp() + caminho = os.path.join(d, "nao-criado-ainda.sqlite") + self.assertFalse(os.path.exists(caminho)) + motor = SyncEngine(Settings(db_path=caminho, enable_web=False, validate_credentials=False)) + return motor.sync_all() + + def test_a_brand_new_gateway_is_not_reported_as_a_failure(self): + resumo = self.ciclo_sem_banco() + self.assertTrue( + resumo.get("success"), + "banco ainda inexistente e o estado normal de uma stack recem subida", + ) + + def test_it_says_it_is_waiting_rather_than_staying_silent(self): + # Silencio seria pior que o erro: o operador precisa saber POR QUE o + # painel esta vazio. + self.assertTrue(self.ciclo_sem_banco().get("waiting_for_gateway")) + + def test_the_summary_still_has_the_shape_the_panel_expects(self): + # O painel le estes campos sem verificar existencia; faltar qualquer um + # trocaria o aviso amigavel por um KeyError na renderizacao. + resumo = self.ciclo_sem_banco() + for campo in ("total_connections", "refreshed", "details", "timestamp"): + self.assertIn(campo, resumo) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_favicon.py b/tests/test_favicon.py new file mode 100644 index 0000000..4392796 --- /dev/null +++ b/tests/test_favicon.py @@ -0,0 +1,62 @@ +"""Toda página servida declara o ícone da aba. + +`/favicon.ico` responde 401 atrás do Basic Auth, então o navegador não consegue +buscá-lo: sem um `` embutido, a aba fica com o quadrado +genérico. O painel é uma aba que o operador deixa aberta o dia inteiro, e três +abas genéricas lado a lado são indistinguíveis. + +O ícone vai como data URI justamente por isso — não depende de requisição, e +por isso funciona também na página de erro, que é servida antes de qualquer +autenticação. +""" + +import pathlib +import re +import sys +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RAIZ / "src")) + +from nine_rtksync import render # noqa: E402 + +RENDER = RAIZ / "src" / "nine_rtksync" / "render.py" + + +class TodaPaginaTemIcone(unittest.TestCase): + def setUp(self): + self.fonte = RENDER.read_text(encoding="utf-8") + + def test_o_icone_e_uma_constante_unica(self): + """Duplicar o SVG em cada página é como as duas cópias divergem.""" + self.assertIsNotNone( + re.search(r"^FAVICON = ", self.fonte, re.M), + "o ícone tem de ser uma constante no topo do módulo", + ) + self.assertEqual( + self.fonte.count("data:image/svg+xml"), + 1, + "o SVG do ícone aparece mais de uma vez: é assim que as cópias divergem", + ) + + def test_todo_documento_servido_declara_o_icone(self): + """Conta os e exige um para cada um.""" + heads = self.fonte.count("") + icones = len(re.findall(r'', self.fonte)) + self.assertEqual( + icones, + heads, + f"{heads} documentos servidos e {icones} declarações de ícone: " + "a aba de algum deles fica com o ícone genérico", + ) + + def test_o_icone_nao_depende_de_requisicao(self): + """Um href para arquivo seria buscado, e /favicon.ico responde 401.""" + self.assertTrue( + render.FAVICON.startswith("data:"), + "o ícone precisa ser data URI: qualquer URL seria buscada e levaria 401", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database.py b/tests/test_gateway.py similarity index 98% rename from tests/test_database.py rename to tests/test_gateway.py index fd001f9..1b0fad1 100644 --- a/tests/test_database.py +++ b/tests/test_gateway.py @@ -6,7 +6,7 @@ import tempfile import unittest -from nine_rtksync.database import ( +from nine_rtksync.gateway import ( get_all_combos, get_all_connections, update_connection_data, diff --git a/tests/test_gateway_diag.py b/tests/test_gateway_diag.py index 53eb654..46d1894 100644 --- a/tests/test_gateway_diag.py +++ b/tests/test_gateway_diag.py @@ -10,7 +10,7 @@ from unittest.mock import patch, MagicMock from nine_rtksync.config import Settings -from nine_rtksync.web.server import start_web_server +from nine_rtksync.web import start_web_server class TestGatewayDiag(unittest.TestCase): @@ -56,7 +56,7 @@ def mock_urlopen(req, *args, **kwargs): return mock_resp return orig_req(req, *args, **kwargs) - with patch("nine_rtksync.web.server.urllib.request.urlopen", side_effect=mock_urlopen): + with patch("nine_rtksync.web.urllib.request.urlopen", side_effect=mock_urlopen): url = f"http://127.0.0.1:{self.settings.web_port}/api/test-gateway" auth = base64.b64encode(b"admin:testpassword").decode("utf-8") req = urllib.request.Request(url, data=b"{}", headers={"Authorization": f"Basic {auth}"}) diff --git a/tests/test_health_semantics.py b/tests/test_health_semantics.py index e8b3855..4591011 100644 --- a/tests/test_health_semantics.py +++ b/tests/test_health_semantics.py @@ -17,7 +17,7 @@ from datetime import datetime, timedelta, timezone from nine_rtksync.models import ConnectionRecord -from nine_rtksync.web.render import render_refresh_reason +from nine_rtksync.render import render_refresh_reason def agora_mais(segundos: int) -> str: diff --git a/tests/test_healthz_documentado.py b/tests/test_healthz_documentado.py index 8d32a5a..18aae5e 100644 --- a/tests/test_healthz_documentado.py +++ b/tests/test_healthz_documentado.py @@ -14,7 +14,7 @@ import unittest RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SERVIDOR = os.path.join(RAIZ, "src", "nine_rtksync", "web", "server.py") +SERVIDOR = os.path.join(RAIZ, "src", "nine_rtksync", "web.py") def respostas_do_healthz() -> set: diff --git a/tests/test_i18n_simetrico.py b/tests/test_i18n_simetrico.py new file mode 100644 index 0000000..2c15fd5 --- /dev/null +++ b/tests/test_i18n_simetrico.py @@ -0,0 +1,46 @@ +"""Os três catálogos de tradução têm de ter exatamente as mesmas chaves. + +Uma chave acrescentada só em inglês não quebra nada: `translate` cai no inglês +em silêncio, e a tela aparece meio traduzida para quem escolheu português ou +espanhol. Ninguém revisa isso num diff — o catálogo é longo e a falta é uma +linha que não existe. + +Também confere os marcadores de interpolação: `cron.result_line` recebe +`inspected`, `findings` e `duration`, e uma tradução que troque um nome desses +devolve o texto sem substituir, sem erro nenhum. +""" + +import re +import unittest + +from nine_rtksync.i18n import DEFAULT_LANGUAGE, LANGUAGES, TRANSLATIONS + +RX_MARCADOR = re.compile(r"\{([a-z_]+)\}") + + +class TestCatalogosSimetricos(unittest.TestCase): + def test_every_language_declares_the_same_keys(self): + referencia = set(TRANSLATIONS[DEFAULT_LANGUAGE]) + for idioma in LANGUAGES: + with self.subTest(idioma=idioma): + faltando = sorted(referencia - set(TRANSLATIONS[idioma])) + sobrando = sorted(set(TRANSLATIONS[idioma]) - referencia) + self.assertEqual(faltando, [], f"chaves ausentes em {idioma}") + self.assertEqual(sobrando, [], f"chaves só em {idioma}") + + def test_every_translation_keeps_the_same_placeholders(self): + for chave, texto in TRANSLATIONS[DEFAULT_LANGUAGE].items(): + esperado = set(RX_MARCADOR.findall(texto)) + for idioma in LANGUAGES: + if idioma == DEFAULT_LANGUAGE: + continue + with self.subTest(chave=chave, idioma=idioma): + self.assertEqual( + set(RX_MARCADOR.findall(TRANSLATIONS[idioma][chave])), + esperado, + f"marcadores diferentes em {idioma}:{chave}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_identidade_isolada.py b/tests/test_identidade_isolada.py new file mode 100644 index 0000000..7316114 --- /dev/null +++ b/tests/test_identidade_isolada.py @@ -0,0 +1,98 @@ +"""A identidade mora em UM arquivo, e este teste é o que torna a frase verdadeira. + +Os três sincronizadores são a mesma aplicação com cor, nome, logo e gateway +diferentes. Isso só se sustenta se o que difere estiver todo num lugar: enquanto +"9Router" aparecer em vinte e três arquivos, qualquer convergência é uma +promessa, e não um fato verificável. + +Este teste roda SOZINHO -- não precisa dos irmãos ao lado. É ele que impede a +divergência de voltar por baixo de `test_irmaos_identicos.py`: uma cor +reintroduzida em `render.py` quebraria a comparação nos três repositórios ao +mesmo tempo, e a saída mais fácil seria reintroduzi-la nos três. Aqui a +reintrodução reprova sozinha, no repositório que a cometeu. + +A lista de arquivos isentos está vazia de propósito. Se algum dia precisar de +uma exceção, ela vira constante nomeada aqui em cima, com o motivo escrito ao +lado -- para aparecer no diff de quem a criou. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent + +# O pacote é o único diretório sob src/ que declara identidade. Descobri-lo em +# vez de escrever o nome é o que permite este arquivo ser o mesmo nos três. +PACOTE = next( + p for p in sorted((RAIZ / "src").iterdir()) if (p / "identidade.py").is_file() +) + +# O único arquivo autorizado a escrever identidade literal. +FRONTEIRA = "identidade.py" + +# Nenhum arquivo isento. Uma lista vazia é uma afirmação, não um esquecimento. +ISENTOS: tuple = () + +# A única cor que pode ser escrita fora de `identidade.py`, e o motivo: ela é +# ESTRUTURAL, não identidade. Os três painéis declaram exatamente este valor +# para `--text`, e `test_identidade_visual.py` cobra isso. Se ela mudasse num +# painel só, quem reprovaria seria aquele teste, não este. Está nomeada aqui +# para aparecer no diff de quem um dia quiser uma segunda exceção. +CORES_ESTRUTURAIS = {"#e6e8ee"} + +PROIBIDOS = [ + (re.compile(r"#[0-9a-fA-F]{6}\b"), "cor fora de identidade.py"), + (re.compile(r"9RTKSync|OminiRTKSync|OminiRTkSync|LiteLlmRTKSync"), "nome de produto literal"), + (re.compile(r"9Router|OmniRoute|LiteLLM"), "nome de gateway literal"), + (re.compile(r"9rtk-|ominirtk-|litellmrtk-"), "prefixo de container literal"), + ( + re.compile(r"bi-lightning-charge-fill|bi-signpost-split-fill|bi-speedometer2"), + "ícone do produto literal", + ), +] + + +def arquivos_do_pacote(): + for caminho in sorted(PACOTE.rglob("*.py")): + if "__pycache__" in caminho.parts: + continue + relativo = caminho.relative_to(PACOTE).as_posix() + if relativo == FRONTEIRA or relativo in ISENTOS: + continue + yield relativo, caminho + + +class IdentidadeNaoVazaDoArquivoDela(unittest.TestCase): + def test_nenhum_modulo_escreve_identidade_a_mao(self): + achados = [] + for relativo, caminho in arquivos_do_pacote(): + for numero, linha in enumerate( + caminho.read_text(encoding="utf-8").splitlines(), 1 + ): + sem_estruturais = linha + for cor in CORES_ESTRUTURAIS: + sem_estruturais = sem_estruturais.replace(cor, "") + for padrao, motivo in PROIBIDOS: + if padrao.search(sem_estruturais): + achados.append(f"{relativo}:{numero} {motivo} -> {linha.strip()[:110]}") + self.assertEqual( + achados, + [], + "identidade fora de identidade.py (importe de lá em vez de escrever):\n" + + "\n".join(achados), + ) + + def test_a_fronteira_existe_e_nao_importa_nada_do_pacote(self): + """Sem import interno, ou o arquivo que todos importam vira um ciclo.""" + fonte = (PACOTE / FRONTEIRA).read_text(encoding="utf-8") + internos = [ + linha + for linha in fonte.splitlines() + if re.match(r"\s*(from\s+\.|import\s+\.)", linha) + ] + self.assertEqual(internos, [], f"identidade.py não pode importar do pacote: {internos}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_identidade_visual.py b/tests/test_identidade_visual.py new file mode 100644 index 0000000..8fa1112 --- /dev/null +++ b/tests/test_identidade_visual.py @@ -0,0 +1,237 @@ +"""Contrato de identidade visual: mesma casca nos três painéis, paleta própria. + +A regra do produto é uma só -- "os frontends devem ter os mesmos componentes, +mas as cores mudam". Isso só se sustenta se a diferença entre os painéis estiver +inteiramente nos VALORES de um conjunto fechado de tokens, e nunca na existência +deles: um token a mais num painel é um componente que só ele sabe desenhar, e a +simetria acaba ali. + +Desde o cânone, os valores não moram mais em `render.py`: moram em +`identidade.py`, o único arquivo do pacote que pode divergir dos irmãos. Este +teste passou a ler de lá, e é por isso que ele mesmo é IDÊNTICO nos três +repositórios — se o contrato fosse diferente em algum deles, este arquivo +precisaria ser diferente, e `tests/test_irmaos_identicos.py` reprovaria. + +Esta guarda existe porque a alternativa é alguém abrir as três telas lado a lado +e reparar. Isso funcionou até parar de funcionar. +""" + +import importlib +import importlib.util +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent + +# O pacote é o único diretório sob src/ que declara identidade. Descobri-lo em +# vez de escrever o nome é o que permite este arquivo ser o mesmo nos três. +PACOTE = next( + p for p in sorted((RAIZ / "src").iterdir()) if (p / "identidade.py").is_file() +) +RENDER = PACOTE / "render.py" + +# Contrato fechado de identidade: exatamente estes nomes, nem um a mais. Um +# nome extra aqui é uma decisão de produto que só um dos três tomou. +CONTRATO_DE_IDENTIDADE = frozenset( + { + "NOME_DO_PRODUTO", + "NOME_DO_GATEWAY", + "PROVEDOR_DO_GATEWAY", + "PREFIXO_DE_CONTAINER", + "PORTA_DO_PAINEL", + "PORTA_DE_METRICAS", + "ICONE_DO_PRODUTO", + "GLIFO_DO_FAVICON", + "COR_DO_FAVICON", + "PALETA", + "NOME_DO_COOKIE", + "NOME_DO_COOKIE_DE_ESTADO", + # As poucas chaves de tradução que dependem do que este gateway faz: o + # 9Router e o OmniRoute renovam credencial OAuth, o LiteLLM só + # inspeciona. Ficam aqui para que o catálogo de i18n.py possa ser o + # MESMO TEXTO nos três -- e é: os três arquivos têm o mesmo md5. + "ROTULOS_DO_PRODUTO", + } +) + +# Tokens de PAPEL: existem nos três, com valores diferentes em cada um. +PAPEIS_CROMATICOS = { + "--bg", "--surface", "--surface-2", "--line", + "--accent", "--accent-2", "--brand-a", "--brand-b", "--text-dim", +} + +# `--text` é estrutural, mas o tema o emite junto da paleta: é conferido no +# CSS DESENHADO, e não no código de render.py. +COR_DO_TEXTO_CANONICA = "#e6e8ee" + +# Tokens ESTRUTURAIS: existem nos três com o MESMO valor. São o que faz o +# Bootstrap obedecer à paleta em vez de trazer a dele, e por isso continuam +# escritos em render.py -- não são identidade de ninguém. +ESTRUTURAIS = { + "--bs-btn-bg": "var(--accent)", + "--bs-btn-border-color": "var(--accent)", + "--bs-btn-color": "var(--bg)", + "--bs-btn-hover-bg": "var(--accent-2)", + "--bs-btn-hover-border-color": "var(--accent-2)", + "--bs-btn-hover-color": "var(--bg)", + "--bs-btn-active-bg": "var(--accent-2)", + "--bs-btn-active-border-color": "var(--accent-2)", + "--bs-btn-active-color": "var(--bg)", + "--bs-table-bg": "transparent", + "--bs-table-border-color": "var(--line)", +} + + +def identidade(): + """Carrega identidade.py pelo caminho, sem depender do nome do pacote.""" + alvo = PACOTE / "identidade.py" + spec = importlib.util.spec_from_file_location("identidade_sob_teste", alvo) + modulo = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modulo) + return modulo + + +def tokens_de(texto): + """Extrai `--token: valor;` de um trecho de CSS ou de código.""" + return { + m.group(1): m.group(2).strip() + for m in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;{}]+);", texto) + } + + +def tokens_escritos_no_render(): + """Tokens ainda declarados com valor literal dentro de render.py.""" + return tokens_de(RENDER.read_text(encoding="utf-8")) + + +def tema_desenhado(): + """O bloco `:root` como o painel realmente o serve.""" + modulo = importlib.import_module(f"{PACOTE.name}.render") + return tokens_de(modulo.tokens_do_tema()) + + +class ContratoDeIdentidade(unittest.TestCase): + def test_declara_exatamente_os_nomes_do_contrato(self): + """Nem a menos (peça sem identidade), nem a mais (decisão de um só).""" + declarados = { + nome + for nome in vars(identidade()) + if nome.isupper() and not nome.startswith("_") + } + self.assertEqual( + declarados, + set(CONTRATO_DE_IDENTIDADE), + "faltando: " + f"{sorted(set(CONTRATO_DE_IDENTIDADE) - declarados)}; sobrando: " + f"{sorted(declarados - set(CONTRATO_DE_IDENTIDADE))}", + ) + + def test_a_paleta_tem_exatamente_os_papeis_cromaticos(self): + """Nove papéis, nos três. Um a mais é um componente que só este desenha.""" + paleta = set(identidade().PALETA) + self.assertEqual( + paleta, + PAPEIS_CROMATICOS, + f"tokens do contrato ausentes: {sorted(PAPEIS_CROMATICOS - paleta)}; " + f"tokens fora do contrato: {sorted(paleta - PAPEIS_CROMATICOS)} -- se a " + "peça é legítima, acrescente o token ao contrato dos TRÊS painéis", + ) + + def test_cada_papel_cromatico_e_uma_cor_literal(self): + """Um `var(...)` aqui seria um papel que depende de outro para existir.""" + for token, valor in identidade().PALETA.items(): + self.assertRegex( + valor, + r"^#[0-9a-fA-F]{6}$", + f"{token} precisa ser uma cor hexadecimal, e não {valor!r}", + ) + + +class ContratoDeTokens(unittest.TestCase): + def test_o_render_declara_so_o_que_e_estrutural(self): + """Papel cromático em render.py é identidade vazando de volta para o comum.""" + declarados = tokens_escritos_no_render() + vazados = set(declarados) & PAPEIS_CROMATICOS + self.assertEqual( + vazados, + set(), + f"papéis cromáticos escritos em render.py: {sorted(vazados)} -- " + "o valor deles pertence a identidade.py", + ) + faltando = set(ESTRUTURAIS) - set(declarados) + self.assertEqual(faltando, set(), f"tokens estruturais ausentes: {sorted(faltando)}") + + def test_o_tema_desenhado_e_a_paleta_mais_o_texto(self): + """O `:root` servido ao navegador tem os nove papéis e a cor do texto.""" + esperado = dict(identidade().PALETA) + esperado["--text"] = COR_DO_TEXTO_CANONICA + self.assertEqual(tema_desenhado(), esperado) + + def test_os_tokens_estruturais_tem_o_valor_canonico(self): + """O que não é cor tem de ser idêntico nos três, ou o componente muda de forma.""" + declarados = tokens_escritos_no_render() + for token, esperado in ESTRUTURAIS.items(): + self.assertEqual( + declarados.get(token), + esperado, + f"{token} é estrutural: os três painéis declaram {esperado!r}", + ) + + +class CoerenciaDaPaleta(unittest.TestCase): + def test_cada_papel_cromatico_tem_a_sua_propria_cor(self): + """Dois papéis com a mesma cor é um papel que deixou de existir. + + Se a borda e a marca valem o mesmo, elas viraram a mesma coisa na tela: + a separação entre superfícies some, ou a marca deixa de se destacar. O + token continua lá, mas não cumpre papel nenhum. + """ + paleta = identidade().PALETA + + # Espelhamento deliberado, igual nos três painéis: o gradiente da marca + # termina exatamente na cor de destaque, então `--brand-b` repete + # `--accent` por decisão de design, e não por descuido. Fica declarado + # aqui para que a guarda cubra o resto sem dar falso positivo nele. + ESPELHOS_INTENCIONAIS = {frozenset({"--accent", "--brand-b"})} + + por_cor = {} + for token, valor in paleta.items(): + por_cor.setdefault(valor.lower(), []).append(token) + colisoes = { + cor: sorted(ts) + for cor, ts in por_cor.items() + if len(ts) > 1 and frozenset(ts) not in ESPELHOS_INTENCIONAIS + } + self.assertEqual(colisoes, {}, "papéis diferentes com a mesma cor: " + repr(colisoes)) + + def test_a_escada_de_profundidade_sobe(self): + """bg mais escuro que surface, surface que surface-2, e a linha acima de todos.""" + paleta = identidade().PALETA + + def luz(token): + v = paleta[token].lstrip("#") + r, g, b = (int(v[i : i + 2], 16) for i in (0, 2, 4)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + escada = ["--bg", "--surface", "--surface-2", "--line"] + valores = [luz(t) for t in escada] + self.assertEqual( + valores, + sorted(valores), + "a escada de profundidade tem de subir: " + + ", ".join(f"{t}={v:.0f}" for t, v in zip(escada, valores)), + ) + + def test_o_favicon_usa_o_tom_de_superficie(self): + """A moldura do ícone é `--surface`: a cor-base fica escura demais em 32px.""" + ident = identidade() + self.assertEqual( + ident.COR_DO_FAVICON.lower(), + ident.PALETA["--surface"].replace("#", "%23").lower(), + "o fundo do favicon é o tom de superfície do tema, percent-encoded", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_irmaos_identicos.py b/tests/test_irmaos_identicos.py new file mode 100644 index 0000000..05940ac --- /dev/null +++ b/tests/test_irmaos_identicos.py @@ -0,0 +1,252 @@ +"""Os módulos comuns são o MESMO texto nos três irmãos, byte a byte. + +9RTKSync, OminiRTkSync e LiteLlmRTKSync são a mesma aplicação. O que muda entre +eles é cor, nome, logo, identidade visual e a quem cada um se conecta — e isso +mora inteiro em `identidade.py` (o que é identidade) e em `gateway.py` (o que é +domínio do gateway). Todo o resto tem de ser o mesmo arquivo, ou "são quase +clones" vira uma promessa que ninguém consegue conferir. + +São DUAS medidas aqui, e elas não usam a mesma régua -- vale saber qual é qual +antes de ler um número: + +- `IDENTICOS` compara byte a byte, sem NORMALIZAR nada: zero substituição. Isso + só é possível porque (a) todo import intra-pacote é relativo nos três, de modo + que o nome do pacote não aparece no texto dos módulos, e (b) depois do cânone, + cor, nome de produto e nome de gateway só existem em `identidade.py`, que não + está na lista. Um teste que normaliza é um teste que aceita divergência: a + cada `re.sub` a mais, um agente ganha uma brecha. Se algum dia for preciso + normalizar alguma coisa, a resposta certa é mover aquilo para `identidade.py`; +- `TETOS_DE_DIVERGENCIA` é a catraca dos módulos que AINDA estão convergindo, e + essa, sim, normaliza a identidade antes de medir -- senão o número seria + dominado pelo que muda de propósito. + +Por isso zerar a catraca não promove um módulo sozinho: pode sobrar um `9Router` +literal que a normalização escondeu. O módulo só entra em `IDENTICOS` quando a +divergência normalizada chega a zero E os termos de identidade sumiram do texto +bruto. + +Num clone isolado, sem os irmãos ao lado, o teste pula em vez de reprovar — +ninguém deve precisar dos três repositórios para rodar a suíte de um. + +A lista cresce a cada módulo que converge. Ela é curta de propósito: só entra +aqui o que JÁ é idêntico, para que o teste fique verde desde o primeiro dia e +qualquer regressão apareça imediatamente, e não no fim de uma migração. +""" + +import difflib +import pathlib +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent + +# Nome do diretório de cada irmão ao lado deste repositório. +IRMAOS = ("9RTKSync", "OminiRTkSync", "LiteLlmRTKSync") + +# Módulos que têm de ser o mesmo texto nos três, relativos a `src//`. +IDENTICOS = ( + "auth.py", + "credential_check.py", + "i18n.py", + "logs.py", + "paginacao.py", + "prefs.py", + "protecao.py", + "sessao.py", +) + +# Módulos que AINDA divergem, com o teto medido em 13/09/2026. A convergência +# é trabalho de várias rodadas, e uma lista que só aceitasse zero deixaria a +# guarda vermelha por semanas -- vermelho permanente é ruído, e ruído se ignora. +# +# Então isto é uma catraca: cada número só pode DESCER. Uma rodada que aumente a +# divergência reprova na hora, mesmo que a suíte inteira esteja verde, porque +# foi assim que os três se separaram em primeiro lugar -- ninguém percebeu. +# +# Quando um módulo chega a zero, ele sai daqui e entra em IDENTICOS. Esse é o +# fim da linha: a lista de baixo vazia e a de cima com tudo. +# +# A medida normaliza o que DEVE mudar (nome do pacote, do produto, prefixo de +# container e nome do gateway) antes de comparar; o que sobra é divergência de +# verdade. +# +# E a medida é feita em ORDEM CANÔNICA: o par é sempre comparado na direção +# dada por IRMAOS, nunca "eu contra ele". Isso não é preciosismo -- o difflib +# indexa a segunda sequência e devolve número diferente quando se troca a ordem +# (o mesmo sso.py deu 1354 de um lado e 1388 do outro). Sem ordem fixa, a suíte +# do 9RTKSync passava e a do LiteLlmRTKSync reprovava com os arquivos idênticos, +# e o teto viraria uma régua que estica conforme quem mede. +# +# Medidos em 13/09/2026, no par que mais diverge de cada módulo. Sem folga de +# propósito: um teto com margem é permissão para piorar um pouco, e "um pouco" +# foi como se chegou a mil linhas de diferença. +# O teto de `web.py` subiu de 759 para 765 numa unica ocasiao, e a razao fica +# registrada aqui porque a catraca existe justamente para exigir isso: seis +# linhas entraram no LiteLlmRTKSync para corrigir uma tela que MENTIA. Com o +# gateway fora do ar, `list_models()` levantava, o `except` devolvia lista vazia +# e o painel dizia "o gateway respondeu com o catalogo vazio" -- o operador ia +# procurar um cadastro faltando em vez de olhar o gateway. +# +# Nos irmaos o estado do catalogo nasce em `gateway.py` e chega pronto ao +# `web.py` em duas linhas; no LiteLlmRTKSync a leitura acontece no proprio +# `web.py`, entao sao quatro. A convergencia completa desse ponto e mover a +# leitura para `gateway.py`, e isso continua em aberto. +TETOS_DE_DIVERGENCIA = { + "i18n.py": 0, + "models.py": 7, + "render.py": 862, + "sso.py": 328, + "web.py": 689, +} + +# O que cada produto troca de propósito, e que não conta como divergência. +IDENTIDADE = ( + ("nine_rtksync", "omini_rtksync", "litellm_rtksync"), + ("9RTKSync", "OminiRTKSync", "OminiRTkSync", "LiteLlmRTKSync"), + ("9rtk", "ominirtk", "litellmrtk"), + ("9Router", "OmniRoute", "LiteLLM"), +) + +# Limite de linhas do diff mostrado na falha: o suficiente para ver o que mudou +# sem despejar um arquivo inteiro no terminal. +LINHAS_DE_DIFF = 40 + + +def ordem_canonica(nome: str) -> int: + """Posição do repositório em IRMAOS; um clone renomeado vai para o fim. + + Serve para fixar a direção da comparação. A comparação ignora maiúsculas + porque nesta máquina o mesmo repositório é alcançado como `9RTKSync` e como + `9rtksync` -- o instalador editável gravou o caminho em minúsculas, e o + sistema de arquivos não distingue. Sem o `.lower()`, quem rodasse a suíte a + partir do caminho minúsculo caía no fim da ordem, media todos os pares + invertidos e via números que não batem com nenhum teto. + """ + alvo = nome.lower() + for i, irmao in enumerate(IRMAOS): + if irmao.lower() == alvo: + return i + return len(IRMAOS) + + +def pacote_de(raiz: pathlib.Path): + """O diretório do pacote dentro de um repositório, pelo `identidade.py`.""" + origem = raiz / "src" + if not origem.is_dir(): + return None + for candidato in sorted(origem.iterdir()): + if (candidato / "identidade.py").is_file(): + return candidato + return None + + +class ModulosComunsSaoOMesmoTexto(unittest.TestCase): + def test_cada_modulo_comum_e_identico_ao_do_irmao(self): + meu_pacote = pacote_de(RAIZ) + self.assertIsNotNone(meu_pacote, "este repositório não tem src//identidade.py") + + for irmao in IRMAOS: + raiz_do_irmao = RAIZ.parent / irmao + if raiz_do_irmao.resolve() == RAIZ: + continue + if not raiz_do_irmao.is_dir(): + with self.subTest(irmao=irmao): + self.skipTest(f"irmão {irmao} não está ao lado; clone isolado") + continue + pacote_do_irmao = pacote_de(raiz_do_irmao) + if pacote_do_irmao is None: + with self.subTest(irmao=irmao): + self.skipTest(f"irmão {irmao} ainda não declara identidade.py") + continue + + for arquivo in IDENTICOS: + with self.subTest(irmao=irmao, arquivo=arquivo): + meu = meu_pacote / arquivo + dele = pacote_do_irmao / arquivo + self.assertTrue(meu.is_file(), f"{arquivo} não existe neste repositório") + self.assertTrue( + dele.is_file(), + f"{arquivo} não existe em {irmao} -- é assim que a simetria " + "some sem ninguém ver", + ) + texto_meu = meu.read_text(encoding="utf-8") + texto_dele = dele.read_text(encoding="utf-8") + if texto_meu == texto_dele: + continue + diferenca = list( + difflib.unified_diff( + texto_meu.splitlines(keepends=True), + texto_dele.splitlines(keepends=True), + fromfile=f"{RAIZ.name}/{arquivo}", + tofile=f"{irmao}/{arquivo}", + ) + ) + recorte = "".join(diferenca[:LINHAS_DE_DIFF]) + if len(diferenca) > LINHAS_DE_DIFF: + recorte += f"... (+{len(diferenca) - LINHAS_DE_DIFF} linhas)\n" + self.fail( + f"{arquivo} difere de {irmao}. Se a diferença é identidade, " + f"ela pertence a identidade.py:\n{recorte}" + ) + + def test_a_divergencia_dos_modulos_em_convergencia_nao_cresce(self): + """Catraca: o que ainda difere só pode diferir menos a cada rodada. + + Sem isto, a convergência depende de alguém lembrar de medir. Foi a falta + dessa medida que deixou o `sso.py` divergir em mil linhas entre irmãos + que saíram do mesmo desenho -- cada agente escreveu do seu jeito e + ninguém comparou. + """ + meu_pacote = pacote_de(RAIZ) + self.assertIsNotNone(meu_pacote) + + def normaliza(caminho): + texto = caminho.read_text(encoding="utf-8", errors="ignore") + for grupo in IDENTIDADE: + for termo in grupo: + texto = texto.replace(termo, "X") + return texto.splitlines() + + estourados = [] + for irmao in IRMAOS: + raiz_do_irmao = RAIZ.parent / irmao + if raiz_do_irmao.resolve() == RAIZ or not raiz_do_irmao.is_dir(): + continue + pacote_do_irmao = pacote_de(raiz_do_irmao) + if pacote_do_irmao is None: + continue + + for arquivo, teto in sorted(TETOS_DE_DIVERGENCIA.items()): + meu, dele = meu_pacote / arquivo, pacote_do_irmao / arquivo + if not (meu.is_file() and dele.is_file()): + continue + # Ordem canônica: quem vem antes em IRMAOS é sempre o lado + # esquerdo, seja ele "eu" ou "ele". Assim os três repositórios + # medem o mesmo par e chegam ao mesmo número. + if ordem_canonica(irmao) < ordem_canonica(RAIZ.name): + esquerda, direita = dele, meu + else: + esquerda, direita = meu, dele + linhas = sum( + 1 + for linha in difflib.unified_diff( + normaliza(esquerda), normaliza(direita), n=0 + ) + if linha[:1] in "+-" and linha[:3] not in ("+++", "---") + ) + if linhas > teto: + estourados.append( + f"{arquivo} contra {irmao}: {linhas} linhas, teto {teto} " + "-- a divergência AUMENTOU" + ) + + self.assertEqual( + estourados, + [], + "a convergência andou para trás:\n " + "\n ".join(estourados) + + "\n\nSe a divergência cresceu de propósito, o teto é que precisa " + "de justificativa -- não o contrário.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_makefile_publica_no_loopback.py b/tests/test_makefile_publica_no_loopback.py new file mode 100644 index 0000000..bd62988 --- /dev/null +++ b/tests/test_makefile_publica_no_loopback.py @@ -0,0 +1,51 @@ +"""Todo mapeamento de porta do Makefile tem de publicar só no loopback. + +O painel lê o banco do gateway e mostra a saúde das credenciais -- a wiki manda +manter a porta dele em `127.0.0.1`, e todos os composes fazem isso. Quem +escapava era o Makefile: `-p 9091:9090` publica em TODA interface, o Wi-Fi do +café, a VLAN do escritório. O teste de portas existente varre apenas +`docker-compose*.yml` e `*.md`, então essa divergência sobreviveu até alguém +achar no olho. Esta guarda fecha o buraco. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +MAKEFILE = RAIZ / "Makefile" + +# -p [IP:]HOSTPORT:CONTAINERPORT +MAPEAMENTO = re.compile(r"-p\s+(?:(\S+):)?(\d+):(\d+)") + + +class MakefilePublicaNoLoopback(unittest.TestCase): + def test_todo_mapeamento_de_porta_prende_no_loopback(self): + self.assertTrue(MAKEFILE.exists(), "o repositório precisa de um Makefile") + achados = [] + for numero, linha in enumerate(MAKEFILE.read_text(encoding="utf-8").splitlines(), 1): + if linha.lstrip().startswith("#"): + continue + for ip, host, _interno in MAPEAMENTO.findall(linha): + if ip not in ("127.0.0.1", "localhost"): + achados.append(f"Makefile:{numero}: -p {ip or ''}{'' if ip else ''}{host}:… publica em toda interface") + self.assertEqual( + achados, + [], + "prenda no loopback (-p 127.0.0.1:PORTA:…):\n " + "\n ".join(achados), + ) + + def test_a_porta_do_painel_e_a_deste_repositorio(self): + """Publicar na porta do irmão mostra este painel no endereço do outro produto.""" + texto = MAKEFILE.read_text(encoding="utf-8") + for ip, host, interno in MAPEAMENTO.findall(texto): + if interno == "9090": + self.assertEqual( + host, + "9091", + f"o painel deste repositório é a porta 9091, não a {host}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nada_sem_login.py b/tests/test_nada_sem_login.py new file mode 100644 index 0000000..f8f0d65 --- /dev/null +++ b/tests/test_nada_sem_login.py @@ -0,0 +1,124 @@ +"""Nada do painel responde sem login, exceto o que precisa ser público. + +O painel lê o banco do gateway e mostra a saúde das credenciais. Uma rota que +escape da exigência de sessão entrega isso a quem chegar — e o jeito de uma rota +escapar não é alguém decidir abri-la, é alguém acrescentar uma rota nova e +esquecer de protegê-la. Por isso esta guarda enumera o despacho do servidor e +cobra o inverso: toda rota é fechada, menos as quatro que têm motivo declarado. + +As públicas, e por quê: + +- `/healthz` o healthcheck do Docker roda sem credencial nenhuma; +- `/login` exigir sessão para exibir o formulário que cria a sessão é um + círculo fechado; +- `/robots.txt` um rastreador não tem como autenticar, e a regra só serve se + ele conseguir lê-la; +- `/credenciais-atualizadas` é servida no instante seguinte à troca de senha, + quando o navegador ainda guarda a anterior; exigir a nova ali + daria um 401 cru logo depois de a troca ter dado certo. +- `/sso/oidc/iniciar` e `/sso/oidc/callback` a ida ao provedor de identidade e a + volta dele acontecem sem sessão -- é a sessão que elas existem + para criar. Exigir sessão aqui seria o mesmo círculo fechado do + `/login`, com uma diferença: a volta é uma navegação vinda de + OUTRO site, e nem o cookie de sessão seria enviado nela. + + O que substitui a sessão nessas duas, e está coberto por + `tests/test_sso.py`: as duas passam pelo mesmo teto de + tentativas por endereço do login (`protecao.registra_tentativa`); + as duas respondem 404 enquanto o SSO não estiver configurado E + ligado, pelo mesmo caminho de qualquer rota inexistente; e o + callback só emite sessão depois de conferir, nesta ordem, o + cookie de estado assinado, o `state`, o `code`, a troca no + emissor, `iss`/`aud`/`exp`/`iat`/`nonce` do id_token, o `sub` do + userinfo, o `email_verified` e a lista de permissão. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +SERVIDOR = RAIZ / "src" / "nine_rtksync" / "web.py" + +PUBLICAS = { + "/healthz", + "/login", + "/robots.txt", + "/credenciais-atualizadas", + "/sso/oidc/iniciar", + "/sso/oidc/callback", + # As duas do SAML2 sao publicas pela MESMA razao que as do OIDC: a ida + # acontece antes de existir sessao, e a volta (`acs`) e um POST do PROVEDOR + # de identidade, que nao carrega cookie nenhum deste painel. Exigir sessao + # nelas tornaria o SSO impossivel -- e nao seria mais seguro: quem entra por + # aqui ainda passa pela validacao de assinatura do proprio SAML. + "/sso/saml/iniciar", + "/sso/saml/acs", +} + +# Rotas citadas no despacho: `route == "/x"`, `path == "/x"`, startswith("/x") +ROTA = re.compile(r'(?:route|path|rota_inicial)\s*==\s*"(/[a-z0-9/_-]*)"') +ROTA_PREFIXO = re.compile(r'startswith\(\s*"(/[a-z0-9/_-]+)"') + + +class NadaRespondeSemLogin(unittest.TestCase): + def setUp(self): + self.fonte = SERVIDOR.read_text(encoding="utf-8") + + def test_toda_rota_publica_esta_declarada_aqui(self): + """Uma rota nova servida antes do require_auth tem de passar por aqui.""" + # Trecho entre o início do do_GET e a primeira exigência de sessão: é + # exatamente o que o servidor entrega sem olhar credencial. + inicio = self.fonte.find("def do_GET") + fim = self.fonte.find("require_auth()", inicio) + self.assertGreater(fim, inicio, "não achei a exigência de sessão no do_GET") + antes_do_login = self.fonte[inicio:fim] + + servidas = set(ROTA.findall(antes_do_login)) + fora_da_lista = servidas - PUBLICAS + self.assertEqual( + fora_da_lista, + set(), + "estas rotas são servidas ANTES de exigir sessão e não estão na lista " + f"de públicas: {sorted(fora_da_lista)} -- se a rota deve mesmo ser " + "pública, acrescente-a à lista COM o motivo; se não, mova-a para " + "depois do require_auth", + ) + + def test_o_post_tambem_exige_sessao(self): + """O POST muda estado: escapar da sessão ali é pior que no GET.""" + inicio = self.fonte.find("def do_POST") + fim = self.fonte.find("require_auth()", inicio) + self.assertGreater(fim, inicio, "o do_POST precisa exigir sessão") + antes = self.fonte[inicio:fim] + servidas = set(ROTA.findall(antes)) + # /login e /logout são POST sem sessão por definição: um a cria, o outro + # a destrói, e exigir sessão para sair é prender quem quer ir embora. + # /sso/saml/acs entra pelo mesmo tipo de razão: quem posta ali é o + # PROVEDOR de identidade, que não tem cookie deste painel. Exigir sessão + # tornaria o SSO impossível -- e o que autoriza a entrada por ali não é + # o cookie, é a assinatura da asserção, verificada antes de qualquer + # sessão ser emitida. + fora = servidas - {"/login", "/logout", "/sso/saml/acs"} + self.assertEqual( + fora, + set(), + f"POST sem exigir sessão: {sorted(fora)}", + ) + + def test_a_exigencia_de_sessao_vem_antes_do_corpo(self): + """Ler o corpo antes de autenticar aceita carga de quem não entrou.""" + inicio = self.fonte.find("def do_POST") + trecho = self.fonte[inicio : inicio + 2000] + pos_auth = trecho.find("require_auth()") + pos_corpo = trecho.find("Content-Length") + if pos_corpo >= 0: + self.assertLess( + pos_auth, + pos_corpo, + "o corpo do POST é lido antes de a sessão ser exigida", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nenhuma_chave_crua.py b/tests/test_nenhuma_chave_crua.py new file mode 100644 index 0000000..c6debc4 --- /dev/null +++ b/tests/test_nenhuma_chave_crua.py @@ -0,0 +1,70 @@ +"""Nenhuma chave de tradução pode chegar crua à tela. + +`translate()` devolve a própria chave quando não encontra o texto. O efeito é +silencioso: nada quebra, nenhum teste falha, e o operador lê `egress.title` no +lugar de "Saída de rede" — foi exatamente o que aconteceu aqui, cinco vezes no +mesmo painel, até alguém abrir a tela e reparar. + +A guarda cobre as chaves escritas literalmente. As montadas em tempo de execução +(`translate(f"health.{status}")`) ficam de fora de propósito: varrê-las por +regex acusaria como órfãs as nove `health.*` que existem e são usadas, e uma +guarda que mente perde a autoridade de reprovar. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +FONTE = RAIZ / "src" / "nine_rtksync" +CATALOGO = FONTE / "i18n.py" + +# translate("chave.literal", ...) — só o primeiro argumento, e só quando é +# string literal. f-strings e variáveis não casam, que é o que se quer. +USO_LITERAL = re.compile(r'translate\(\s*"([a-z0-9_]+(?:\.[a-z0-9_]+)+)"') +DECLARACAO = re.compile(r'^\s*"([a-z0-9_]+(?:\.[a-z0-9_]+)+)"\s*:', re.M) + + +def chaves_declaradas(): + return set(DECLARACAO.findall(CATALOGO.read_text(encoding="utf-8"))) + + +def chaves_usadas(): + usadas = {} + for arquivo in sorted(FONTE.rglob("*.py")): + if arquivo.name == "i18n.py": + continue + texto = arquivo.read_text(encoding="utf-8") + for numero, linha in enumerate(texto.splitlines(), 1): + for chave in USO_LITERAL.findall(linha): + usadas.setdefault(chave, f"{arquivo.relative_to(RAIZ)}:{numero}") + return usadas + + +class NenhumaChaveCruaNaTela(unittest.TestCase): + def test_toda_chave_usada_existe_no_catalogo(self): + declaradas = chaves_declaradas() + orfas = {k: onde for k, onde in chaves_usadas().items() if k not in declaradas} + self.assertEqual( + orfas, + {}, + "estas chaves chegariam cruas à tela:\n " + + "\n ".join(f"{k} (usada em {onde})" for k, onde in sorted(orfas.items())), + ) + + def test_os_tres_idiomas_declaram_o_mesmo_conjunto(self): + """Faltar num idioma é o mesmo defeito, visível só para quem usa aquele idioma.""" + texto = CATALOGO.read_text(encoding="utf-8") + # Cada bloco de idioma começa numa linha do tipo `"en": {` + blocos = re.split(r'^\s*"(?:en|pt|es)"\s*:\s*\{', texto, flags=re.M)[1:] + self.assertEqual(len(blocos), 3, "esperava três blocos de idioma no catálogo") + conjuntos = [set(DECLARACAO.findall(b)) for b in blocos] + for i, outro in enumerate(conjuntos[1:], start=1): + faltando = conjuntos[0] - outro + sobrando = outro - conjuntos[0] + self.assertEqual(faltando, set(), f"bloco {i} não declara: {sorted(faltando)}") + self.assertEqual(sobrando, set(), f"bloco {i} declara a mais: {sorted(sobrando)}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paginacao.py b/tests/test_paginacao.py new file mode 100644 index 0000000..8563e4c --- /dev/null +++ b/tests/test_paginacao.py @@ -0,0 +1,103 @@ +"""A paginação dos grids: dez por página, e o total continua sendo o total. + +Um catálogo de gateway chega a centenas de modelos — 550 numa medição — e sem +paginar a tela rola por minutos. O que estes testes protegem não é o corte em +si, que é trivial, mas as três decisões em volta dele, cada uma com uma +alternativa óbvia e pior. +""" + +import unittest + +from nine_rtksync import paginacao + + +class RecorteDaPagina(unittest.TestCase): + def setUp(self): + self.itens = list(range(1, 556)) # 555 itens: 55 páginas cheias e 5 na última + + def test_a_primeira_pagina_traz_dez(self): + fatia, estado = paginacao.recortar(self.itens, "modelos") + self.assertEqual(len(fatia), 10) + self.assertEqual(fatia[0], 1) + self.assertEqual(estado["pagina"], 1) + + def test_o_total_nao_e_o_tamanho_da_pagina(self): + """O cabeçalho mostra quantos existem, não quantos couberam.""" + _, estado = paginacao.recortar(self.itens, "modelos") + self.assertEqual(estado["total"], 555) + self.assertEqual(estado["ultima"], 56) + + def test_cada_grid_le_o_proprio_parametro(self): + """Avançar os modelos não pode mover a tabela de conexões.""" + consulta = {"pag_modelos": ["3"]} + fatia_modelos, _ = paginacao.recortar(self.itens, "modelos", consulta) + fatia_conexoes, estado_conexoes = paginacao.recortar(self.itens, "conexoes", consulta) + self.assertEqual(fatia_modelos[0], 21) + self.assertEqual(fatia_conexoes[0], 1, "o outro grid ficou onde estava") + self.assertEqual(estado_conexoes["pagina"], 1) + + def test_pagina_alem_do_fim_vira_a_ultima(self): + """Acontece sozinho quando o catálogo encolhe e o link antigo sobrevive.""" + fatia, estado = paginacao.recortar(self.itens, "modelos", {"pag_modelos": ["999"]}) + self.assertEqual(estado["pagina"], estado["ultima"]) + self.assertTrue(fatia, "a última página não pode vir vazia") + + def test_lixo_na_query_nao_derruba_a_pagina(self): + for ruim in ("abc", "", "-3", "0", "1.5"): + _, estado = paginacao.recortar(self.itens, "modelos", {"pag_modelos": [ruim]}) + self.assertGreaterEqual(estado["pagina"], 1) + + def test_lista_vazia_nao_quebra(self): + fatia, estado = paginacao.recortar([], "modelos") + self.assertEqual(fatia, []) + self.assertEqual(estado["total"], 0) + self.assertEqual(estado["ultima"], 1) + + def test_a_ultima_pagina_traz_o_resto(self): + fatia, _ = paginacao.recortar(self.itens, "modelos", {"pag_modelos": ["56"]}) + self.assertEqual(len(fatia), 5) + self.assertEqual(fatia[-1], 555) + + +class BarraDePaginas(unittest.TestCase): + def traduzir(self, chave, lang, **kwargs): + return f"{chave}:{kwargs}" + + def test_some_quando_ha_uma_pagina_so(self): + """Uma barra de paginação com uma página é ruído.""" + _, estado = paginacao.recortar([1, 2, 3], "modelos") + self.assertEqual(paginacao.render_paginacao(estado, {}, self.traduzir, "pt"), "") + + def test_os_links_preservam_os_outros_parametros(self): + """Sem isso, avançar os modelos zeraria a página das conexões.""" + consulta = {"pag_conexoes": ["4"], "lang": ["pt"]} + _, estado = paginacao.recortar(list(range(50)), "modelos", consulta) + html = paginacao.render_paginacao(estado, consulta, self.traduzir, "pt") + self.assertIn("pag_conexoes=4", html) + self.assertIn("lang=pt", html) + + def test_o_aviso_da_ultima_acao_nao_e_repetido(self): + """Ele é de uso único: repeti-lo faria a mensagem reaparecer sem fim.""" + consulta = {"aviso": ["Ciclo executado"], "tom": ["success"]} + _, estado = paginacao.recortar(list(range(50)), "modelos", consulta) + html = paginacao.render_paginacao(estado, consulta, self.traduzir, "pt") + self.assertNotIn("aviso=", html) + self.assertNotIn("tom=", html) + + def test_a_barra_nao_lista_todas_as_paginas(self): + """Com 56 páginas, listar todas daria uma barra maior que a tabela.""" + _, estado = paginacao.recortar(list(range(555)), "modelos", {"pag_modelos": ["28"]}) + html = paginacao.render_paginacao(estado, {}, self.traduzir, "pt") + self.assertLessEqual(html.count("page-item"), 12) + self.assertIn("…", html, "a janela precisa indicar que há mais páginas") + + def test_os_links_sao_links_de_verdade(self): + """A página é montada no servidor e funciona com o script desligado.""" + _, estado = paginacao.recortar(list(range(50)), "modelos") + html = paginacao.render_paginacao(estado, {}, self.traduzir, "pt") + self.assertIn(':X:Y"` citado na documentação seja um deles. + +O padrão aceita qualquer endereço de bind, não só `127.0.0.1`: a página de +acesso remoto publica o gateway no endereço da tailnet, e enquanto o regex +exigia o loopback um `"100.101.102.103:20128:20128"` passava sem ser conferido +-- que foi exatamente como a porta 20128 do host, reservada à stack do artigo, +sobreviveu na documentação. """ import os @@ -14,7 +20,7 @@ import unittest RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -RX_PORTA = re.compile(r'-\s*"127\.0\.0\.1:(\d+):(\d+)"') +RX_PORTA = re.compile(r'-\s*"(?:\d{1,3}(?:\.\d{1,3}){3}:)?(\d+):(\d+)"') def composes(): @@ -49,7 +55,7 @@ def test_every_port_mapping_in_the_docs_matches_a_compose(self): for host, interna in mapeamentos(doc): if (host, interna) not in reais: divergentes.append( - f"{os.path.relpath(doc, RAIZ)}: 127.0.0.1:{host}:{interna} " + f"{os.path.relpath(doc, RAIZ)}: {host}:{interna} " f"não corresponde a nenhum compose deste repositório" ) self.assertEqual( diff --git a/tests/test_protecao.py b/tests/test_protecao.py new file mode 100644 index 0000000..4442cc0 --- /dev/null +++ b/tests/test_protecao.py @@ -0,0 +1,152 @@ +"""O freio contra força bruta: teto, espera que cresce e prova de trabalho. + +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 instalado quando ele apertar. +""" + +import hashlib +import time +import unittest + +from nine_rtksync import protecao + + +class TetoPorJanela(unittest.TestCase): + def setUp(self): + protecao.limpa_apos_sucesso("10.0.0.1") + + def test_ate_o_teto_passa(self): + for _ in range(protecao.TENTATIVAS_POR_JANELA): + pode, _ = protecao.registra_tentativa("10.0.0.1") + self.assertTrue(pode) + + def test_passar_do_teto_manda_esperar(self): + for _ in range(protecao.TENTATIVAS_POR_JANELA): + protecao.registra_tentativa("10.0.0.1") + pode, espere = protecao.registra_tentativa("10.0.0.1") + self.assertFalse(pode) + self.assertGreater(espere, 0, "o 429 precisa dizer QUANTO esperar") + + def test_um_endereco_nao_bloqueia_o_outro(self): + """Senão um único atacante derruba o acesso de todo mundo.""" + for _ in range(protecao.TENTATIVAS_POR_JANELA + 5): + protecao.registra_tentativa("10.0.0.1") + pode, _ = protecao.registra_tentativa("10.0.0.2") + self.assertTrue(pode) + protecao.limpa_apos_sucesso("10.0.0.2") + + +class EsperaQueCresce(unittest.TestCase): + def setUp(self): + protecao.limpa_apos_sucesso("10.0.0.3") + + def test_sem_falha_nao_ha_espera(self): + self.assertEqual(protecao.espera_por_falhas("10.0.0.3"), 0.0) + + def test_cada_falha_dobra_a_espera(self): + protecao.anota_falha("10.0.0.3") + uma = protecao.espera_por_falhas("10.0.0.3") + protecao.anota_falha("10.0.0.3") + duas = protecao.espera_por_falhas("10.0.0.3") + self.assertGreater(duas, uma) + + def test_a_espera_tem_teto(self): + """Sem teto, o processo ficaria preso segurando a resposta.""" + for _ in range(40): + protecao.anota_falha("10.0.0.3") + self.assertLessEqual( + protecao.espera_por_falhas("10.0.0.3"), protecao.ESPERA_MAXIMA_EM_SEGUNDOS + ) + + def test_acertar_a_senha_limpa_a_suspeita(self): + for _ in range(5): + protecao.anota_falha("10.0.0.3") + protecao.limpa_apos_sucesso("10.0.0.3") + self.assertEqual(protecao.espera_por_falhas("10.0.0.3"), 0.0) + + +class ProvaDeTrabalho(unittest.TestCase): + def setUp(self): + protecao.limpa_apos_sucesso("10.0.0.4") + + def test_so_e_exigido_depois_de_algumas_falhas(self): + self.assertFalse(protecao.precisa_de_desafio("10.0.0.4")) + for _ in range(protecao.FALHAS_ATE_DESAFIO): + protecao.anota_falha("10.0.0.4") + self.assertTrue(protecao.precisa_de_desafio("10.0.0.4")) + + def test_resposta_correta_passa(self): + desafio = protecao.novo_desafio() + detalhes = protecao.detalhes_do_desafio(desafio) + self.assertIsNotNone(detalhes) + self.assertTrue(protecao.resposta_confere(desafio, detalhes["alvo"])) + + def test_resposta_errada_nao_passa(self): + desafio = protecao.novo_desafio() + self.assertFalse(protecao.resposta_confere(desafio, "opcao_invalida_xyz")) + + def test_a_mesma_resposta_nao_serve_duas_vezes(self): + """Sem consumo, um bot resolveria uma vez e repetiria para sempre.""" + desafio = protecao.novo_desafio() + detalhes = protecao.detalhes_do_desafio(desafio) + self.assertTrue(protecao.resposta_confere(desafio, detalhes["alvo"])) + self.assertFalse(protecao.resposta_confere(desafio, detalhes["alvo"])) + + def test_desafio_inventado_nao_passa(self): + self.assertFalse(protecao.resposta_confere("desafio-que-nunca-emiti", "key")) + + def test_entrada_vazia_nao_derruba(self): + for desafio, resposta in (("", ""), ("x", ""), ("", "y")): + self.assertFalse(protecao.resposta_confere(desafio, resposta)) + + def test_detalhes_do_desafio_traz_opcoes_e_alvo_valido(self): + desafio = protecao.novo_desafio() + detalhes = protecao.detalhes_do_desafio(desafio) + self.assertEqual(len(detalhes["opcoes"]), protecao.DIFICULDADE) + self.assertIn(detalhes["alvo"], detalhes["opcoes"]) + + +class DificuldadeQueCresce(unittest.TestCase): + """Mais falhas aumentam o número de opções para reduzir chance de acerto ao acaso.""" + + def setUp(self): + protecao.limpa_apos_sucesso("10.0.0.9") + + def test_quem_nunca_errou_paga_o_minimo(self): + self.assertEqual(protecao.dificuldade_para("10.0.0.9"), protecao.DIFICULDADE) + + def test_insistir_encarece(self): + for _ in range(protecao.FALHAS_ATE_DESAFIO + 6): + protecao.anota_falha("10.0.0.9") + self.assertGreater( + protecao.dificuldade_para("10.0.0.9"), + protecao.DIFICULDADE, + "quem insiste tem de pagar mais caro a cada bloco de falhas", + ) + + def test_a_dificuldade_tem_teto(self): + for _ in range(200): + protecao.anota_falha("10.0.0.9") + self.assertLessEqual( + protecao.dificuldade_para("10.0.0.9"), protecao.DIFICULDADE_MAXIMA + ) + + def test_novo_desafio_respeita_quantidade(self): + desafio = protecao.novo_desafio(quantidade=6) + detalhes = protecao.detalhes_do_desafio(desafio) + self.assertEqual(len(detalhes["opcoes"]), 6) + self.assertIn(detalhes["alvo"], detalhes["opcoes"]) + + +class EnderecoDoCliente(unittest.TestCase): + def test_ignora_a_porta_de_origem(self): + """A porta muda a cada conexão; contar por ela não limitaria nada.""" + self.assertEqual(protecao.endereco_do_cliente(("192.168.0.7", 54321)), "192.168.0.7") + + def test_entrada_estranha_nao_derruba(self): + self.assertEqual(protecao.endereco_do_cliente(None), "desconhecido") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rodape_em_toda_tela.py b/tests/test_rodape_em_toda_tela.py new file mode 100644 index 0000000..f550565 --- /dev/null +++ b/tests/test_rodape_em_toda_tela.py @@ -0,0 +1,109 @@ +"""A assinatura da Pathbit aparece em TODA tela, e uma vez só. + +O pedido foi "nos footers coloque algo assim em tudo". "Em tudo" é literal: o +rodapé existia apenas no painel, e a tela de LOGIN -- a única que alguém vê sem +estar autenticado, e portanto a mais pública das quatro -- não tinha nenhum. + +Este teste renderiza as quatro telas e conta a assinatura em cada uma. Conta, +e não procura: quando o rodapé passou a vir de uma função, a versão escrita à +mão continuou no painel por um momento, e as duas apareceram juntas. "Existe" +não era suficiente; "existe uma vez" é. + +O ano não é conferido contra um literal de propósito -- ele vem do relógio, e um +teste que fixasse 2026 quebraria sozinho em primeiro de janeiro, que é +exatamente o defeito que o `datetime.now().year` existe para evitar. +""" + +import datetime +import os +import re +import sys +import unittest + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(RAIZ, "src")) + + +def pacote(): + origem = os.path.join(RAIZ, "src") + for nome in sorted(os.listdir(origem)): + if os.path.isfile(os.path.join(origem, nome, "identidade.py")): + return nome + raise AssertionError("este repositório não tem src//identidade.py") + + +PACOTE = pacote() + + +class ORodapeApareceEmTodaTela(unittest.TestCase): + def setUp(self): + from importlib import import_module + self.render = import_module(f"{PACOTE}.render") + + def telas(self): + """As telas do PRODUTO -- que não são todas as páginas servidas. + + `render_notice_page` fica de fora, e a razão é de segurança: ela é o + corpo das respostas 401 e 429, servidas a quem ainda não se autenticou, + e a assinatura traz "Pathbit" e o ano na mesma linha -- exatamente os + dois pedaços da senha deste projeto. Existe um teste + (`test_the_401_body_never_teaches_the_credentials`) que proíbe essas + palavras ali, porque um dia houve naquele corpo um banner que ensinava a + credencial. A saída certa não é afrouxar aquele teste: é não dar a ele + nada para encontrar. + """ + return { + "entrada": lambda: self.render.render_landing_page("pt"), + "login": lambda: self.render.render_login_page(lang="pt"), + "painel": lambda: self.render.render_dashboard(lang="pt"), + } + + def test_a_pagina_de_aviso_nao_traz_a_assinatura(self): + """O corpo do 401 e do 429 não pode carregar pista da credencial.""" + html = self.html(lambda: self.render.render_notice_page("título", "corpo")) + self.assertNotIn( + "Pathbit", html, + "a assinatura entrou no corpo do 401/429: ela traz o nome e o ano, " + "que são os dois pedaços da senha do projeto", + ) + + def html(self, desenha): + saida = desenha() + return saida.decode("utf-8") if isinstance(saida, bytes) else saida + + def test_cada_tela_traz_a_assinatura_uma_vez(self): + for nome, desenha in self.telas().items(): + with self.subTest(tela=nome): + vezes = self.html(desenha).count("pela Pathbit") + self.assertEqual( + vezes, 1, + f"a tela '{nome}' traz a assinatura {vezes} vez(es). Zero " + f"significa que 'em tudo' não valeu para ela; mais de uma " + f"significa que a versão escrita à mão sobreviveu ao lado " + f"da função.", + ) + + def test_o_ano_vem_do_relogio_e_nao_do_teclado(self): + html = self.html(self.telas()["login"]) + achado = re.search(r"reserved \(c\) (\d{4})", html) + self.assertIsNotNone(achado, "não achei o ano no rodapé") + self.assertEqual( + achado.group(1), str(datetime.datetime.now().year), + "o ano do rodapé não é o ano corrente -- se ele estiver escrito à " + "mão, fica errado em primeiro de janeiro e ninguém revisa rodapé", + ) + + def test_o_coracao_e_icone_e_nunca_emoji(self): + """O cabeçalho do render fixa "Bootstrap Icons, nunca emoji".""" + html = self.html(self.telas()["login"]) + self.assertIn("bi-heart-fill", html, "o coração tem de ser Bootstrap Icon") + for emoji in ("💜", "❤", "♥"): + self.assertNotIn( + emoji, html, + f"emoji {emoji!r} no rodapé: o desenho muda conforme o sistema " + f"de quem olha, e a regra do projeto é ícone de fonte", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rotas_simetricas.py b/tests/test_rotas_simetricas.py new file mode 100644 index 0000000..e0b0d70 --- /dev/null +++ b/tests/test_rotas_simetricas.py @@ -0,0 +1,127 @@ +"""Os três painéis servem exatamente as mesmas rotas. + +Uma rota que existe num irmão e não nos outros é o jeito mais silencioso de os +três se separarem: nada quebra, a suíte fica verde, e a diferença só aparece +quando alguém segue a documentação de um produto usando o outro. + +Aconteceu de verdade com o SAML: a implementação foi portada para o `sso.py` dos +três -- as funções `saml_disponivel`, `url_de_ida_saml` e `processa_resposta_saml` +existem em todos -- mas as rotas `/sso/saml/*` ficaram só no LiteLlmRTKSync. +Resultado: dois produtos com o código de federação inteiro e nenhuma porta para +entrar nele. O `sso.py` convergiu, o `web.py` não, e nenhum teste percebeu. + +Este teste lê o conjunto de rotas declarado por cada `web.py` e exige que os três +sejam iguais. Ele não roda o servidor: compara o contrato, que é o que diverge. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +IRMAOS = ("9RTKSync", "OminiRTkSync", "LiteLlmRTKSync") + +# Rotas que o produto TEM de servir, independentemente do gateway por trás. +# Entram aqui as que algum pedido explícito criou -- assim o teste também +# documenta por que cada uma existe. +ROTAS_OBRIGATORIAS = { + "/": "a tela inicial", + "/login": "o formulário próprio, que substituiu o Basic Auth", + "/logout": "sair de verdade, limpando a sessão", + "/healthz": "a sonda que o compose usa", + "/robots.txt": "inibir indexação", + "/favicon.ico": "o ícone da aba -- responde 204, porque o ícone real é\n um data URI embutido no HTML", + "/sso/oidc/iniciar": "a ida ao provedor OIDC", + "/sso/oidc/callback": "a volta do provedor OIDC", + "/sso/saml/iniciar": "a ida ao provedor SAML2", + "/sso/saml/acs": "o Assertion Consumer Service, a volta do SAML2", + "/sso/saml/metadata": "o metadata que o provedor SAML2 consome", +} + + +def pacote_de(raiz): + origem = raiz / "src" + if not origem.is_dir(): + return None + for candidato in sorted(origem.iterdir()): + if (candidato / "identidade.py").is_file(): + return candidato + return None + + +def rotas_de(caminho_do_web): + """As rotas que o servidor declara, sem rodá-lo. + + Lê só onde rota é DECLARADA -- o conjunto `ROTAS_CONHECIDAS` e as comparações + de caminho no despacho. Pegar qualquer literal `"/..."` do arquivo era + frágil: uma URL de CDN numa docstring ou um `log("acesso a /logs negado")` + viraria rota, e o teste acusaria divergência que não existe. + """ + texto = caminho_do_web.read_text(encoding="utf-8") + rotas = set() + + # 1. O conjunto declarado, que é o que decide entre servir e devolver 404. + bloco = re.search(r"ROTAS_CONHECIDAS\s*=\s*\{(.*?)\}", texto, re.S) + if bloco: + rotas |= set(re.findall(r'"(/[a-z0-9/_.-]*)"', bloco.group(1))) + + # 2. O despacho: `if caminho == "/x"` / `elif self.path == "/x"` e o + # startswith que trata uma família inteira. + rotas |= set(re.findall(r'(?:path|caminho|rota)\s*==\s*"(/[a-z0-9/_.-]*)"', texto)) + rotas |= set(re.findall(r'startswith\(\s*"(/[a-z0-9/_.-]*)"', texto)) + + # 3. Os prefixos declarados cobrem tudo abaixo deles. + bloco = re.search(r"PREFIXOS_CONHECIDOS\s*=\s*\((.*?)\)", texto, re.S) + if bloco: + rotas |= set(re.findall(r'"(/[a-z0-9/_.-]*)"', bloco.group(1))) + + return rotas + + +def coberta_por_prefixo(rota, rotas): + """Uma rota servida por um prefixo declarado conta como servida.""" + return any(p.endswith("/") and rota.startswith(p) for p in rotas) + + +class OsTresServemAsMesmasRotas(unittest.TestCase): + def setUp(self): + self.meu_pacote = pacote_de(RAIZ) + self.assertIsNotNone(self.meu_pacote, "este repositório não tem src//identidade.py") + self.minhas_rotas = rotas_de(self.meu_pacote / "web.py") + + def test_serve_todas_as_rotas_obrigatorias(self): + for rota, motivo in sorted(ROTAS_OBRIGATORIAS.items()): + with self.subTest(rota=rota): + self.assertTrue( + rota in self.minhas_rotas or coberta_por_prefixo(rota, self.minhas_rotas), + f"{rota} não é servida aqui -- ela existe para {motivo}. Se o " + f"código por trás dela já está no repositório e só falta a " + f"rota, o produto tem a funcionalidade e nenhuma porta para " + f"ela: foi o que aconteceu com o SAML2.", + ) + + def test_nenhum_irmao_serve_rota_que_os_outros_nao_servem(self): + """Simetria nos dois sentidos: sobrar rota também é divergir.""" + for irmao in IRMAOS: + raiz_do_irmao = RAIZ.parent / irmao + if raiz_do_irmao.resolve() == RAIZ or not raiz_do_irmao.is_dir(): + continue + pacote_do_irmao = pacote_de(raiz_do_irmao) + if pacote_do_irmao is None: + continue + with self.subTest(irmao=irmao): + dele = rotas_de(pacote_do_irmao / "web.py") + so_minhas = sorted(self.minhas_rotas - dele) + so_dele = sorted(dele - self.minhas_rotas) + self.assertEqual( + (so_minhas, so_dele), ([], []), + f"as rotas divergem de {irmao}.\n" + f" só em {RAIZ.name}: {so_minhas}\n" + f" só em {irmao}: {so_dele}\n" + "Rota que existe num e não no outro separa os produtos sem " + "quebrar nada -- é assim que a simetria some sem ninguém ver.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sessao.py b/tests/test_sessao.py new file mode 100644 index 0000000..689795f --- /dev/null +++ b/tests/test_sessao.py @@ -0,0 +1,68 @@ +"""A sessão do painel: cookie assinado, com prazo, que não se pode forjar. + +O painel nasceu só com Basic Auth. O diálogo que o navegador abre para isso é +janela DELE, não página nossa: não se traduz, não se estiliza, não tem logout, e +qualquer ferramenta que dirija um navegador para ali, porque não há campo HTML +para preencher. O formulário resolve os quatro — e o Basic Auth continua aceito, +porque é ele que faz `curl` e monitoramento funcionarem sem sessão. +""" + +import time +import unittest + +from nine_rtksync import sessao + + +class CookieDeSessao(unittest.TestCase): + def test_o_cookie_emitido_identifica_quem_entrou(self): + valor = sessao.emitir("admin") + self.assertEqual(sessao.usuario_da_sessao(valor), "admin") + + def test_cookie_adulterado_nao_vale(self): + """Trocar o usuário dentro do cookie tem de invalidar a assinatura.""" + valor = sessao.emitir("admin") + corpo, _, assinatura = valor.rpartition(".") + forjado = corpo[:-4] + "AAAA." + assinatura + self.assertIsNone(sessao.usuario_da_sessao(forjado)) + + def test_assinatura_trocada_nao_vale(self): + valor = sessao.emitir("admin") + corpo, _, _ = valor.rpartition(".") + self.assertIsNone(sessao.usuario_da_sessao(corpo + "." + "0" * 64)) + + def test_cookie_vencido_nao_vale(self): + """Emitido no passado, com prazo já corrido.""" + antigo = sessao.emitir("admin", agora=time.time() - sessao.VALIDADE_EM_SEGUNDOS - 60) + self.assertIsNone(sessao.usuario_da_sessao(antigo)) + + def test_lixo_nao_derruba_a_verificacao(self): + for entrada in ("", "sem-ponto", "a.b", "...", "x" * 200): + self.assertIsNone(sessao.usuario_da_sessao(entrada)) + + def test_o_cookie_e_inacessivel_ao_script_da_pagina(self): + """HttpOnly e SameSite são o que impedem roubo por XSS e por outro site.""" + cabecalho = sessao.cabecalho_para_gravar("qualquer") + self.assertIn("HttpOnly", cabecalho) + self.assertIn("SameSite=Strict", cabecalho) + self.assertIn("Path=/", cabecalho) + + def test_sair_apaga_o_cookie(self): + self.assertIn("Max-Age=0", sessao.cabecalho_para_apagar()) + + def test_le_o_cookie_no_meio_de_outros(self): + valor = sessao.emitir("admin") + cru = f"tema=escuro; {sessao.NOME_DO_COOKIE}={valor}; idioma=pt" + self.assertEqual(sessao.ler_do_cabecalho(cru), valor) + self.assertEqual(sessao.ler_do_cabecalho("outro=1"), "") + + def test_o_nome_do_cookie_e_proprio_deste_produto(self): + """Três painéis no mesmo 127.0.0.1 compartilham o espaço de cookies. + + Cookies não se separam por porta: um nome genérico faria o login num + painel derrubar a sessão dos outros dois. + """ + self.assertTrue(sessao.NOME_DO_COOKIE.startswith("9rtksync")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sso.py b/tests/test_sso.py new file mode 100644 index 0000000..51c29cc --- /dev/null +++ b/tests/test_sso.py @@ -0,0 +1,1212 @@ +"""O caminho feliz do SSO não prova nada. Estes testes exercitam o caminho ruim. + +Um fluxo federado é uma sequência de conferências, e cada conferência que falta +é uma porta. Testar só a entrada bem-sucedida mede se a porta abre — não mede se +ela fecha, que é a única coisa que importa aqui. + +O que cada bloco cobre: + +- `EnderecoPublicoDoPainel` o endereço de retorno nasce da configuração, e não + do cabeçalho `Host`, que quem chama escolhe; +- `ListaDePermissao` lista vazia nunca significa "todo mundo"; +- `Descoberta` o emissor do documento tem de ser o configurado; +- `CargaDoIdToken` `iss`, `aud`, `azp`, `exp`, `iat` e `nonce`, um a um; +- `CookieDeEstado` assinatura trocada, prazo vencido e a troca entre o + cookie de estado e o de sessão; +- `VoltaDoProvedor` o fluxo inteiro contra um provedor falso, com uma + falha diferente por teste; +- `PainelDeVerdade` um painel de pé e um provedor de identidade de pé, + com `code` reapresentado, `state` trocado e cabeçalho `Host` hostil; +- `PendentesDoSaml` o conjunto que dá sentido ao `InResponseTo` e o + cache que impede a mesma asserção de valer duas vezes; +- `EnderecosDoSaml` tudo o que o IdP recebe sai de `sso.base_url`, e + nunca do cabeçalho `Host`. +""" + +import base64 +import http.client +import json +import os +import socket +import sqlite3 +import tempfile +import threading +import time +import unittest +import urllib.parse +import zlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from nine_rtksync import protecao, sessao, sso +from nine_rtksync.config import Settings +from nine_rtksync.web import start_web_server + +CLIENT_ID = "cliente-do-painel" +CLIENT_SECRET = "segredo-de-teste-que-nunca-sai-daqui" + + +def _b64url(dados: bytes) -> str: + return base64.urlsafe_b64encode(dados).decode("ascii").rstrip("=") + + +def monta_id_token(carga: dict) -> str: + """Um JWT com assinatura de enfeite. + + A assinatura NÃO é verificada aqui nem em produção: o token chega pelo canal + direto com o `token_endpoint`, sobre TLS e com o cliente autenticado, que é o + caso dispensado pela OIDC Core 3.1.3.7. Por isso o teste pode montar o token + à mão sem mentir sobre o que o código faz. + """ + cabecalho = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode("utf-8")) + corpo = _b64url(json.dumps(carga).encode("utf-8")) + return f"{cabecalho}.{corpo}.assinatura-de-enfeite" + + +def porta_livre() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def permitido(email: str, dominios=(), emails=()) -> bool: + """A lista de autorizados do núcleo, no formato curto que estes testes usam.""" + return sso.email_autorizado( + email, sso.ConfiguracaoSSO(dominios=tuple(dominios), emails=tuple(emails)) + ) + + +# -------------------------------------------------------------------------- +# Configuração e listas +# -------------------------------------------------------------------------- + + +class EnderecoPublicoDoPainel(unittest.TestCase): + """Derivar o endereço de retorno do `Host` é a definição de redirecionamento aberto.""" + + def test_aceita_origem_https_sem_caminho(self): + self.assertTrue(sso.base_url_valida("https://painel.exemplo.com")) + self.assertTrue(sso.base_url_valida("https://painel.exemplo.com/")) + self.assertTrue(sso.base_url_valida("https://painel.exemplo.com:8443")) + + def test_recusa_caminho_consulta_e_fragmento(self): + """Um caminho aqui vira um endereço de retorno que o provedor não reconhece.""" + for ruim in ( + "https://painel.exemplo.com/sub", + "https://painel.exemplo.com/?x=1", + "https://painel.exemplo.com/#a", + ): + self.assertFalse(sso.base_url_valida(ruim), ruim) + + def test_http_so_no_loopback(self): + self.assertTrue(sso.base_url_valida("http://127.0.0.1:9091")) + self.assertTrue(sso.base_url_valida("http://localhost:9091")) + self.assertFalse(sso.base_url_valida("http://painel.exemplo.com")) + + def test_recusa_esquema_estranho(self): + self.assertFalse(sso.base_url_valida("javascript:alert(1)")) + self.assertFalse(sso.base_url_valida("")) + + def test_o_endereco_de_retorno_sai_da_configuracao(self): + config = {"base_url": "https://painel.exemplo.com"} + self.assertEqual( + sso.redirect_uri(config), "https://painel.exemplo.com/sso/oidc/callback" + ) + + +class ListaDePermissao(unittest.TestCase): + def test_lista_vazia_nao_deixa_ninguem_entrar(self): + """Sem filtro, "entrar com o provedor X" significa que toda conta X entra.""" + self.assertFalse(permitido("qualquer@gmail.com", [], [])) + + def test_dominio_autorizado(self): + self.assertTrue(permitido("chefe@empresa.com", ["empresa.com"], [])) + self.assertFalse(permitido("chefe@outra.com", ["empresa.com"], [])) + + def test_email_exato(self): + self.assertTrue(permitido("chefe@empresa.com", [], ["chefe@empresa.com"])) + self.assertFalse(permitido("outro@empresa.com", [], ["chefe@empresa.com"])) + + def test_um_dominio_parecido_nao_passa(self): + """`empresa.com.br` não é `empresa.com`, e `naoempresa.com` também não.""" + self.assertFalse(permitido("a@empresa.com.br", ["empresa.com"], [])) + self.assertFalse(permitido("a@naoempresa.com", ["empresa.com"], [])) + + def test_sem_arroba_nao_e_email(self): + self.assertFalse(permitido("empresa.com", ["empresa.com"], [])) + + def test_a_configuracao_recusa_ligar_sem_lista(self): + campos = { + "base_url": "https://painel.exemplo.com", + "issuer": "https://accounts.exemplo.com", + "client_id": CLIENT_ID, + "allowed_domains": "", + "allowed_emails": "", + } + self.assertIn("sso.need_allowlist", sso.problemas_da_configuracao(campos, True)) + + def test_a_configuracao_recusa_ligar_sem_segredo(self): + campos = { + "base_url": "https://painel.exemplo.com", + "issuer": "https://accounts.exemplo.com", + "client_id": CLIENT_ID, + "allowed_domains": "empresa.com", + "allowed_emails": "", + } + self.assertEqual(sso.problemas_da_configuracao(campos, True), ()) + self.assertIn("sso.need_secret", sso.problemas_da_configuracao(campos, False)) + + +class Descoberta(unittest.TestCase): + """O provedor de mentira entra no lugar da ÚNICA saída de rede do módulo. + + Trocar `sso._pedir` -- e não passar um transporte por argumento -- é o que + exercita o mesmo caminho que roda em produção: quem chama a descoberta no + painel não escolhe transporte nenhum. + """ + + def setUp(self): + sso.limpa_cache_descoberta() + self.pedir_de_verdade = sso._pedir + self.chamadas = [] + + def tearDown(self): + sso._pedir = self.pedir_de_verdade + sso.limpa_cache_descoberta() + + def _documento(self, **troca): + base = { + "issuer": "https://accounts.exemplo.com", + "authorization_endpoint": "https://accounts.exemplo.com/auth", + "token_endpoint": "https://accounts.exemplo.com/token", + "userinfo_endpoint": "https://accounts.exemplo.com/userinfo", + } + base.update(troca) + return base + + def _responde(self, resposta): + """Instala o provedor falso. `resposta` pode ser um documento ou um erro.""" + def falso(url, dados=None, cabecalhos=None, timeout=None): + self.chamadas.append(url) + if isinstance(resposta, Exception): + raise resposta + return resposta + + sso._pedir = falso + + def test_emissor_diferente_do_configurado_e_recusado(self): + """É a defesa contra a confusão entre provedores: um só emissor legítimo.""" + self._responde(self._documento(issuer="https://outro.exemplo.com")) + self.assertIsNone(sso.descobre("https://accounts.exemplo.com")) + + def test_ponto_de_acesso_em_http_e_recusado(self): + self._responde(self._documento(token_endpoint="http://accounts.exemplo.com/token")) + self.assertIsNone(sso.descobre("https://accounts.exemplo.com")) + + def test_documento_bom_passa_e_fica_memorizado(self): + doc = self._documento() + self._responde(doc) + self.assertEqual(sso.descobre("https://accounts.exemplo.com"), doc) + self.assertEqual(sso.descobre("https://accounts.exemplo.com"), doc) + self.assertEqual(len(self.chamadas), 1, "a descoberta tem de ser memorizada") + + def test_a_falha_tambem_e_memorizada(self): + """Sem isto, um provedor fora do ar faria cada tela de login esperar 5s.""" + self._responde(OSError("provedor fora do ar")) + self.assertIsNone(sso.descobre("https://accounts.exemplo.com")) + self.assertIsNone(sso.descobre("https://accounts.exemplo.com")) + self.assertEqual(len(self.chamadas), 1) + + +class DesafioPkce(unittest.TestCase): + def test_s256_bate_com_o_exemplo_da_especificacao(self): + """Vetor do apêndice B da RFC 7636: se o cálculo mudar, o provedor recusa.""" + verificador = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + self.assertEqual( + sso.desafio_de(verificador), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ) + + def test_o_desafio_nao_leva_preenchimento(self): + self.assertNotIn("=", sso.desafio_de(sso.novo_verificador())) + + +# -------------------------------------------------------------------------- +# A carga do id_token, campo a campo +# -------------------------------------------------------------------------- + + +class CargaDoIdToken(unittest.TestCase): + EMISSOR = "https://accounts.exemplo.com" + NONCE = "nonce-desta-ida" + + def carga(self, **troca): + agora = 1_700_000_000 + base = { + "iss": self.EMISSOR, + "aud": CLIENT_ID, + "exp": agora + 600, + "iat": agora, + "nonce": self.NONCE, + "sub": "conta-123", + } + base.update(troca) + return base + + def confere(self, carga, agora=1_700_000_000): + """Devolve o motivo da recusa, ou "" quando a carga passa inteira.""" + config = sso.ConfiguracaoSSO(issuer=self.EMISSOR, client_id=CLIENT_ID) + try: + sso.confere_id_token(carga, config, self.NONCE, agora=agora) + except sso.FalhaDeSSO as erro: + return erro.detalhe + return "" + + def test_a_carga_boa_passa(self): + self.assertEqual(self.confere(self.carga()), "") + + def test_emissor_diferente(self): + self.assertIn("iss", self.confere(self.carga(iss="https://impostor.exemplo.com"))) + + def test_audiencia_de_outro_cliente(self): + """Um token legítimo emitido para OUTRO serviço não vale aqui.""" + self.assertIn("aud", self.confere(self.carga(aud="outro-cliente"))) + + def test_audiencia_em_lista_com_o_nosso_cliente_passa(self): + carga = self.carga(aud=[CLIENT_ID, "outro"], azp=CLIENT_ID) + self.assertEqual(self.confere(carga), "") + + def test_varias_audiencias_sem_azp_nosso_e_recusado(self): + carga = self.carga(aud=[CLIENT_ID, "outro"], azp="outro") + self.assertIn("azp", self.confere(carga)) + + def test_token_expirado(self): + self.assertIn("expirado", self.confere(self.carga(), agora=1_700_000_601)) + + def test_emitido_fora_da_tolerancia_de_relogio(self): + """Relógio do container fora de hora tem de dizer isso, e não 'senha errada'.""" + problema = self.confere(self.carga(iat=1_700_000_000 - 3600)) + self.assertIn("relógio", problema) + + def test_nonce_diferente_do_cookie(self): + self.assertIn("nonce", self.confere(self.carga(nonce="nonce-de-outra-ida"))) + + def test_sem_exp_nao_passa(self): + carga = self.carga() + del carga["exp"] + self.assertIn("exp", self.confere(carga)) + + def test_carga_ilegivel(self): + """Corpo que não é um JWT não vira `None` silencioso: vira recusa.""" + for ruim in ("isto-nao-e-um-jwt", "a.b.c"): + with self.assertRaises(sso.FalhaDeSSO): + sso.decodifica_payload(ruim) + + +# -------------------------------------------------------------------------- +# O cookie de ida e volta +# -------------------------------------------------------------------------- + + +class CookieDeEstado(unittest.TestCase): + def test_o_que_foi_gravado_volta_inteiro(self): + valor = sessao.emitir_estado_sso("st", "no", "ve") + self.assertEqual( + sessao.ler_estado_sso(valor), + {"state": "st", "nonce": "no", "verificador": "ve"}, + ) + + def test_assinatura_adulterada_nao_passa(self): + valor = sessao.emitir_estado_sso("st", "no", "ve") + corpo, _, _assinatura = valor.rpartition(".") + self.assertIsNone(sessao.ler_estado_sso(f"{corpo}.{'0' * 64}")) + + def test_carga_adulterada_nao_passa(self): + outro = base64.urlsafe_b64encode(b"outro|no|ve|99999999999").decode("ascii") + valor = sessao.emitir_estado_sso("st", "no", "ve") + self.assertIsNone(sessao.ler_estado_sso(f"{outro}.{valor.rsplit('.', 1)[1]}")) + + def test_prazo_vencido_nao_passa(self): + agora = time.time() + valor = sessao.emitir_estado_sso("st", "no", "ve", agora=agora) + self.assertIsNone( + sessao.ler_estado_sso( + valor, agora=agora + sessao.VALIDADE_DO_ESTADO_EM_SEGUNDOS + 1 + ) + ) + + def test_um_cookie_nao_serve_de_outro(self): + """Sem separar os domínios da assinatura, um estado forjado viraria sessão.""" + estado = sessao.emitir_estado_sso("st", "no", "ve") + self.assertIsNone(sessao.usuario_da_sessao(estado)) + sessao_valida = sessao.emitir("admin") + self.assertIsNone(sessao.ler_estado_sso(sessao_valida)) + + def test_o_cookie_de_estado_nao_e_strict(self): + """`Strict` não é enviado na volta do provedor: o login falharia em silêncio.""" + cabecalho = sessao.cabecalho_para_gravar_estado("x") + self.assertIn("SameSite=Lax", cabecalho) + self.assertIn("Path=/sso/", cabecalho) + self.assertIn("HttpOnly", cabecalho) + + def test_apagar_repete_o_caminho(self): + """Sem o mesmo `Path`, o navegador guarda o cookie que julgamos consumido.""" + self.assertIn("Path=/sso/", sessao.cabecalho_para_apagar_estado()) + self.assertIn("Max-Age=0", sessao.cabecalho_para_apagar_estado()) + + +# -------------------------------------------------------------------------- +# O fluxo inteiro contra um provedor falso +# -------------------------------------------------------------------------- + + +class VoltaDoProvedor(unittest.TestCase): + EMISSOR = "https://accounts.exemplo.com" + + def setUp(self): + sso.limpa_cache_descoberta() + # O `state` vale uma vez só por processo, e todos os testes desta classe + # usam o mesmo: sem zerar, o segundo já chegaria como reapresentação. + sso.esquece_estado_de_fluxo() + self.pedir_de_verdade = sso._pedir + self.agora = 1_700_000_000 + self.config = { + "base_url": "https://painel.exemplo.com", + "issuer": self.EMISSOR, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "scopes": "openid email profile", + "allowed_domains": "empresa.com", + "allowed_emails": "", + } + self.estado = {"state": "st", "nonce": "no", "verificador": "ve"} + self.parametros = {"state": "st", "code": "codigo-de-autorizacao"} + self.carga = { + "iss": self.EMISSOR, + "aud": CLIENT_ID, + "exp": self.agora + 600, + "iat": self.agora, + "nonce": "no", + "sub": "conta-123", + } + self.perfil = { + "sub": "conta-123", + "email": "chefe@empresa.com", + "email_verified": True, + } + self.pedidos = [] + sso._pedir = self.transporte + + def tearDown(self): + sso._pedir = self.pedir_de_verdade + sso.limpa_cache_descoberta() + + def transporte(self, url, dados=None, cabecalhos=None, timeout=None): + # O corpo chega urlencodado em bytes, como o `token_endpoint` recebe. + campos = urllib.parse.parse_qs((dados or b"").decode("utf-8")) + self.pedidos.append({ + "url": url, + "dados": {chave: valor[0] for chave, valor in campos.items()}, + "cabecalhos": cabecalhos, + }) + if url.endswith("/.well-known/openid-configuration"): + return { + "issuer": self.EMISSOR, + "authorization_endpoint": f"{self.EMISSOR}/auth", + "token_endpoint": f"{self.EMISSOR}/token", + "userinfo_endpoint": f"{self.EMISSOR}/userinfo", + } + if url.endswith("/token"): + return { + "id_token": monta_id_token(self.carga), + "access_token": "token-de-acesso", + } + if url.endswith("/userinfo"): + return self.perfil + raise AssertionError(f"pedido inesperado: {url}") + + def conclui(self, **troca): + argumentos = { + "config": self.config, + "estado": self.estado, + "parametros": self.parametros, + "agora": self.agora, + } + argumentos.update(troca) + return sso.conclui_login(**argumentos) + + def test_caminho_feliz(self): + email, motivo = self.conclui() + self.assertEqual(motivo, "") + self.assertEqual(email, "chefe@empresa.com") + + def test_sem_cookie_de_estado_e_recusado(self): + """Aceitar sem cookie é aceitar um pedido de login forjado por terceiro.""" + email, motivo = self.conclui(estado=None) + self.assertEqual(email, "") + self.assertIn("cookie de estado", motivo) + + def test_state_trocado_e_recusado(self): + email, motivo = self.conclui( + parametros={"state": "state-do-atacante", "code": "c"} + ) + self.assertEqual(email, "") + self.assertIn("state", motivo) + + def test_erro_do_provedor_e_recusado(self): + email, motivo = self.conclui( + parametros={"state": "st", "error": "access_denied"} + ) + self.assertEqual(email, "") + + def test_code_vazio_e_recusado(self): + email, _ = self.conclui(parametros={"state": "st", "code": ""}) + self.assertEqual(email, "") + + def test_o_endereco_de_retorno_enviado_ao_provedor_sai_da_configuracao(self): + """O `Host` da requisição nunca entra nesta conta -- quem chama o escolhe.""" + self.conclui() + troca = [p for p in self.pedidos if p["url"].endswith("/token")][0] + self.assertEqual( + troca["dados"]["redirect_uri"], "https://painel.exemplo.com/sso/oidc/callback" + ) + self.assertEqual(troca["dados"]["code_verifier"], "ve") + self.assertEqual(troca["dados"]["grant_type"], "authorization_code") + + def test_id_token_expirado_e_recusado(self): + self.carga["exp"] = self.agora - 1 + email, motivo = self.conclui() + self.assertEqual(email, "") + self.assertIn("expirado", motivo) + + def test_audiencia_errada_e_recusada(self): + self.carga["aud"] = "outro-cliente" + email, motivo = self.conclui() + self.assertEqual(email, "") + self.assertIn("aud", motivo) + + def test_nonce_de_outra_ida_e_recusado(self): + self.carga["nonce"] = "nonce-de-outra-ida" + email, motivo = self.conclui() + self.assertEqual(email, "") + self.assertIn("nonce", motivo) + + def test_sub_do_userinfo_diferente_e_recusado(self): + """Ancorar o `sub` é o que liga o perfil lido ao token recebido.""" + self.perfil["sub"] = "outra-conta" + email, motivo = self.conclui() + self.assertEqual(email, "") + self.assertIn("sub", motivo) + + def test_email_nao_confirmado_e_recusado(self): + self.perfil["email_verified"] = False + email, motivo = self.conclui() + self.assertEqual(email, "") + self.assertIn("verificado", motivo) + + def test_email_fora_da_lista_e_recusado(self): + self.perfil["email"] = "estranho@gmail.com" + email, motivo = self.conclui() + self.assertEqual(email, "") + self.assertIn("lista de permissão", motivo) + + def test_resposta_do_token_sem_access_token_e_recusada(self): + def transporte(url, dados=None, cabecalhos=None, timeout=None): + if url.endswith("/token"): + return {"id_token": monta_id_token(self.carga)} + return self.transporte(url, dados, cabecalhos, timeout) + + sso._pedir = transporte + email, motivo = self.conclui() + self.assertEqual(email, "") + + def test_o_cliente_se_autentica_na_troca(self): + self.conclui() + troca = [p for p in self.pedidos if p["url"].endswith("/token")][0] + self.assertTrue(troca["cabecalhos"]["Authorization"].startswith("Basic ")) + + def test_a_recusa_nao_conta_em_que_ponto_o_atacante_parou(self): + """Detalhe vai para o log; a tela recebe sempre a mesma frase. + + Este teste mede o contrato: `conclui_login` devolve o motivo SEPARADO do + e-mail, justamente para que quem responde na tela use uma frase só. + """ + for troca in ({"estado": None}, {"parametros": {"state": "x", "code": "c"}}): + email, motivo = self.conclui(**troca) + self.assertEqual(email, "") + self.assertTrue(motivo) + + +# -------------------------------------------------------------------------- +# Um painel de pé, com um provedor de identidade de pé +# -------------------------------------------------------------------------- + + +class ProvedorFalso(BaseHTTPRequestHandler): + """Provedor de identidade mínimo, em loopback, para o painel conversar de verdade.""" + + emissor = "" + nonce = "" + codigos_gastos = set() + redirect_uris_recebidos = [] + + def log_message(self, *a): + pass + + def _json(self, corpo, status=200): + dados = json.dumps(corpo).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(dados))) + self.end_headers() + self.wfile.write(dados) + + def do_GET(self): + if self.path.startswith("/.well-known/openid-configuration"): + base = type(self).emissor + self._json({ + "issuer": base, + "authorization_endpoint": f"{base}/auth", + "token_endpoint": f"{base}/token", + "userinfo_endpoint": f"{base}/userinfo", + }) + return + if self.path.startswith("/userinfo"): + self._json({ + "sub": "conta-123", + "email": "chefe@empresa.com", + "email_verified": True, + }) + return + self.send_error(404) + + def do_POST(self): + if not self.path.startswith("/token"): + self.send_error(404) + return + tamanho = int(self.headers.get("Content-Length", 0)) + campos = urllib.parse.parse_qs(self.rfile.read(tamanho).decode("utf-8")) + codigo = (campos.get("code") or [""])[0] + type(self).redirect_uris_recebidos.append((campos.get("redirect_uri") or [""])[0]) + # O provedor invalida o código na primeira troca. É a defesa de quem + # emite; a nossa é o cookie de uso único, testada logo abaixo. + if codigo in type(self).codigos_gastos: + self._json({"error": "invalid_grant"}, status=400) + return + type(self).codigos_gastos.add(codigo) + agora = int(time.time()) + self._json({ + "access_token": "token-de-acesso", + "id_token": monta_id_token({ + "iss": type(self).emissor, + "aud": CLIENT_ID, + "exp": agora + 600, + "iat": agora, + "nonce": type(self).nonce, + "sub": "conta-123", + }), + }) + + +class PainelDeVerdade(unittest.TestCase): + """Painel e provedor de identidade de pé, conversando por HTTP no loopback.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + cls.db_path = os.path.join(cls.tmp.name, "data.sqlite") + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, provider TEXT, " + "name TEXT, data TEXT, created_at TEXT, updated_at TEXT)" + ) + conn.execute( + "CREATE TABLE combos (id TEXT PRIMARY KEY, name TEXT, kind TEXT, " + "models TEXT, created_at TEXT, updated_at TEXT)" + ) + + cls.porta_idp = porta_livre() + ProvedorFalso.emissor = f"http://127.0.0.1:{cls.porta_idp}" + cls.idp = ThreadingHTTPServer(("127.0.0.1", cls.porta_idp), ProvedorFalso) + cls.idp.daemon_threads = True + threading.Thread(target=cls.idp.serve_forever, daemon=True).start() + + cls.porta = porta_livre() + # DATA_DIR vence o diretório do banco em `get_auth_file_path`: se a + # variável estiver definida no ambiente de quem roda a suíte, o painel + # gravaria a configuração em outro lugar e o teste mediria a coisa errada. + cls._data_dir_anterior = os.environ.pop("DATA_DIR", None) + cls.settings = Settings( + db_path=cls.db_path, + web_host="127.0.0.1", + web_port=cls.porta, + dashboard_user="admin", + dashboard_password="SenhaLocal1!", + ) + cls.base_url = f"http://127.0.0.1:{cls.porta}" + cls.prefs = cls.settings.get_prefs_path() + cls.base_dir = os.path.dirname(cls.settings.get_auth_file_path()) + + cls.server = start_web_server( + host="127.0.0.1", + port=cls.porta, + db_path=cls.db_path, + settings=cls.settings, + ) + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.idp.shutdown() + cls.idp.server_close() + cls.tmp.cleanup() + if cls._data_dir_anterior is not None: + os.environ["DATA_DIR"] = cls._data_dir_anterior + + def setUp(self): + # O teto por endereço é global ao processo, e cada teste faz várias + # tentativas: sem zerar, o oitavo teste responderia 429 e mediria isso. + protecao.limpa_apos_sucesso("127.0.0.1") + sso.limpa_cache_descoberta() + ProvedorFalso.codigos_gastos = set() + ProvedorFalso.redirect_uris_recebidos = [] + os.environ.pop("SSO_DISABLED", None) + os.environ.pop("OIDC_CLIENT_SECRET", None) + + # -- utilidades ------------------------------------------------------ + + def liga_sso(self): + sso.grava_configuracao(self.prefs, { + "enabled": "oidc", + "base_url": self.base_url, + "issuer": ProvedorFalso.emissor, + "client_id": CLIENT_ID, + "scopes": "openid email profile", + "allowed_domains": "empresa.com", + "allowed_emails": "", + }) + sso.grava_segredo(self.base_dir, CLIENT_SECRET) + + def desliga_sso(self): + sso.grava_configuracao(self.prefs, {"enabled": ""}) + + def pede(self, caminho, *, cabecalhos=None, metodo="GET", corpo=None): + """Devolve (status, cabeçalhos, corpo). + + Os cabeçalhos saem como a mensagem HTTP crua, e não como dicionário: a + resposta do callback traz DOIS `Set-Cookie` -- a sessão e o descarte do + cookie de ida -- e um dicionário guardaria só o último, que é como um + teste passa a medir a coisa errada. + """ + conexao = http.client.HTTPConnection("127.0.0.1", self.porta, timeout=5) + try: + conexao.request(metodo, caminho, body=corpo, headers=cabecalhos or {}) + resposta = conexao.getresponse() + return resposta.status, resposta.headers, resposta.read().decode("utf-8") + finally: + conexao.close() + + @staticmethod + def cookies(cabecalhos): + return cabecalhos.get_all("Set-Cookie") or [] + + def cookie_de_sessao(self, cabecalhos): + """O cookie de sessão emitido na resposta, ou "" quando não houve nenhum.""" + for bruto in self.cookies(cabecalhos): + if bruto.startswith(sessao.NOME_DO_COOKIE + "="): + return bruto.split(";")[0] + return "" + + def inicia_fluxo(self): + """Faz a ida e devolve (cookie_de_estado, state).""" + status, cabecalhos, _ = self.pede("/sso/oidc/iniciar") + self.assertEqual(status, 302, "a ida ao provedor tem de ser um 302") + cookie = self.cookies(cabecalhos)[0].split(";")[0] + consulta = urllib.parse.parse_qs( + urllib.parse.urlsplit(cabecalhos["Location"]).query + ) + ProvedorFalso.nonce = consulta["nonce"][0] + return cookie, consulta["state"][0] + + # -- sem configuração, nada muda ------------------------------------- + + def test_sem_configuracao_as_rotas_nao_existem(self): + """"Sem configuração, nada muda" tem de valer também para as rotas novas.""" + self.desliga_sso() + for caminho in ("/sso/oidc/iniciar", "/sso/oidc/callback?code=x&state=y"): + status, _, _ = self.pede(caminho) + self.assertEqual(status, 404, caminho) + + def test_sem_configuracao_a_tela_de_login_e_a_de_hoje(self): + self.desliga_sso() + status, _, corpo = self.pede("/login", cabecalhos={"Accept": "text/html"}) + self.assertEqual(status, 200) + self.assertNotIn("/sso/oidc/iniciar", corpo) + self.assertIn('name="senha"', corpo, "o formulário local não pode sair da tela") + + def test_com_configuracao_o_botao_aparece_ao_lado_do_formulario(self): + self.liga_sso() + status, _, corpo = self.pede("/login", cabecalhos={"Accept": "text/html"}) + self.assertEqual(status, 200) + self.assertIn('href="/sso/oidc/iniciar"', corpo) + self.assertIn('name="senha"', corpo, "o formulário local nunca sai da tela") + self.assertNotIn( + "' + with self.assertRaises(sso.FalhaDeSSO): + sso.in_response_to(base64.b64encode(xml).decode()) + + def test_o_in_response_to_e_lido_da_resposta(self): + xml = b'' + self.assertEqual(sso.in_response_to(base64.b64encode(xml).decode()), "_pendente-1") + + +class EnderecosDoSaml(unittest.TestCase): + """Tudo sai de `sso.base_url`, e nada do cabeçalho `Host`.""" + + def setUp(self): + self.config = sso.ConfiguracaoSSO( + provedor="saml", base_url="https://painel.exemplo.com", + idp_entity_id="https://idp.exemplo.com/metadata", + idp_sso_url="https://idp.exemplo.com/sso", + idp_cert="MIIC-de-mentira", dominios=("empresa.com",), + ) + + def test_a_authn_request_aponta_para_o_acs_da_configuracao(self): + xml = sso.monta_authn_request(self.config, "_id", agora=1_700_000_000) + self.assertIn('AssertionConsumerServiceURL="https://painel.exemplo.com/sso/saml/acs"', xml) + self.assertIn('Destination="https://idp.exemplo.com/sso"', xml) + self.assertIn("https://painel.exemplo.com/sso/saml/metadata", xml) + + def test_o_binding_de_ida_e_por_redirecionamento(self): + """HTTP-POST binding bateria na CSP `form-action 'self'` e seria bloqueado.""" + xml = sso.monta_authn_request(self.config, "_id", agora=1_700_000_000) + destino = sso.url_de_ida_saml(self.config, xml) + self.assertTrue(destino.startswith("https://idp.exemplo.com/sso?SAMLRequest=")) + parametro = urllib.parse.parse_qs(urllib.parse.urlsplit(destino).query)["SAMLRequest"][0] + recuperado = zlib.decompress(base64.b64decode(parametro), -zlib.MAX_WBITS).decode("utf-8") + self.assertEqual(recuperado, xml) + + def test_a_biblioteca_recebe_o_destino_da_configuracao_e_nao_da_requisicao(self): + """O default da `python3-saml` monta a URL do ACS a partir do `http_host`.""" + ajustes = sso.configuracao_da_biblioteca(self.config) + self.assertTrue(ajustes["strict"]) + self.assertTrue(ajustes["security"]["wantAssertionsSigned"]) + self.assertTrue(ajustes["security"]["wantMessagesSigned"]) + self.assertTrue(ajustes["security"]["rejectUnsolicitedResponsesWithInResponseTo"]) + self.assertEqual( + ajustes["sp"]["assertionConsumerService"]["url"], + "https://painel.exemplo.com/sso/saml/acs", + ) + dados = sso.dados_da_requisicao(self.config, {"SAMLResponse": "x"}) + self.assertEqual(dados["http_host"], "painel.exemplo.com") + self.assertEqual(dados["https"], "on") + + def test_a_porta_viaja_dentro_do_host_e_nao_num_campo_proprio(self): + """O campo separado, vazio, montava "https://host:/sso/saml/acs". + + Com dois-pontos e sem número — e a biblioteca recusava a própria + asserção correta dizendo que o destino não batia. Encontrado na bancada, + com asserção assinada de verdade; preso aqui para que o defeito não + volte numa máquina onde a biblioteca nem está instalada. + """ + com_porta = sso.ConfiguracaoSSO(base_url="http://127.0.0.1:9093") + dados = sso.dados_da_requisicao(com_porta, {}) + self.assertEqual(dados["http_host"], "127.0.0.1:9093") + self.assertEqual(dados["https"], "off") + self.assertNotIn("server_port", dados) + + def test_sem_a_biblioteca_o_painel_recusa_em_vez_de_quebrar(self): + if sso.saml_disponivel(): + self.skipTest("a biblioteca está instalada nesta máquina") + with self.assertRaises(sso.FalhaDeSSO): + sso.processa_resposta_saml(self.config, "qualquer-coisa", "_id") + +class SamlNoModuloEAindaSemRota(unittest.TestCase): + """O SAML2 já está escrito aqui; falta a rota que o expõe. + + A biblioteca que confere a assinatura XML não está nesta imagem, e a aba da + tela continua dizendo isso. O que mudou é que o módulo não finge mais: o + fluxo inteiro existe e é o MESMO texto dos irmãos. + """ + + def test_a_deteccao_da_biblioteca_nao_explode(self): + """`saml_disponivel` decide qual das duas frases a aba exibe, e nada mais. + + Ela NÃO é uma asserção sobre o ambiente: instalar `python3-saml` é + exatamente o primeiro passo da próxima fase, e um teste que ficasse + vermelho nesse momento reprovaria a suíte por um motivo que o código não + causou. + """ + self.assertIsInstance(sso.saml_disponivel(), bool) + + def test_a_aba_saml_diz_por_que_esta_desabilitada(self): + from nine_rtksync.render import render_sso_modal + + modal = render_sso_modal({"saml_disponivel": sso.saml_disponivel()}, "pt") + self.assertIn("SAML 2.0", modal) + # Campos desenhados e traduzidos, mas travados: nada de aceitar uma + # configuração que o painel não sabe usar. + self.assertIn("sso_saml_idp_cert", modal) + self.assertIn("disabled", modal) + + def test_as_tres_rotas_de_saml_existem(self): + """O que era estado intermediário virou o estado final. + + Este teste já afirmou o contrário -- que NÃO havia rota de SAML aqui -- + e estava certo enquanto o `web.py` não tinha as portas: rota que existe + e sempre recusa é pior que rota que não existe. As três portas existem + agora nos três painéis, então a afirmação se inverte. + + A TELA ainda não oferece o provedor neste painel: `ler_configuracao` + não lê os campos do IdP, e a camada que a tela usa é a que ainda não + convergiu com a do LiteLlmRTKSync. O núcleo e as rotas, sim. + """ + fonte = ( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + "/src/nine_rtksync/web.py" + ) + with open(fonte, encoding="utf-8") as f: + texto = f.read() + for rota in ("/sso/saml/iniciar", "/sso/saml/acs", "/sso/saml/metadata"): + self.assertIn( + rota, texto, + f"{rota} sumiu: o módulo tem a federação inteira e o painel " + f"ficaria de novo com código sem porta", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_um_leitor_de_data_so.py b/tests/test_um_leitor_de_data_so.py new file mode 100644 index 0000000..0ce7a1c --- /dev/null +++ b/tests/test_um_leitor_de_data_so.py @@ -0,0 +1,95 @@ +"""O produto lê data de um jeito só. + +Havia dois leitores para o mesmo campo. `models.to_epoch_ms` lia carimbo ISO sem +fuso como UTC -- que é como os gateways gravam -- e +`normalizer.parse_iso_or_str_to_ms` deixava o Python assumir o fuso da máquina. +Nesta máquina (-03) isso dava três horas de diferença para o mesmo texto. + +Não era diferença acadêmica: o normalizer é quem decide APAGAR a trava de rate +limit quando ela venceu, e o models é quem decide MOSTRAR `rate_limited` na +tela. Com leitores que discordam, o painel podia desenhar uma trava que o +próprio sincronizador já tinha considerado vencida -- ou pior, o sincronizador +apagar uma trava que ainda valia. + +Nenhum teste cobria isso, e foi um cético lendo o diff que percebeu. Este teste +existe para que os dois não voltem a divergir: qualquer carimbo tem de valer o +mesmo instante para os dois módulos. +""" + +import os +import sys +import unittest + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(RAIZ, "src")) + + +def pacote(): + origem = os.path.join(RAIZ, "src") + for nome in sorted(os.listdir(origem)): + if os.path.isfile(os.path.join(origem, nome, "identidade.py")): + return nome + raise AssertionError("este repositório não tem src//identidade.py") + + +PACOTE = pacote() + +# Os formatos que os gateways realmente gravam, incluindo os tortos que deram +# origem a esta família de projetos. +CARIMBOS = ( + "2026-09-12T15:20:22", # ISO sem fuso -- o caso que divergia + "2026-09-12T15:20:22Z", # ISO em UTC explícito + "2026-09-12T15:20:22.336Z", # com milissegundos + "2026-09-12T15:20:22+00:00", # deslocamento explícito + "1789226422000", # epoch em ms, gravado como TEXTO + 1789226422000, # epoch em ms, numérico + 1789226422, # epoch em segundos + "", # vazio + None, # ausente +) + + +class OsDoisModulosLeemAMesmaData(unittest.TestCase): + def setUp(self): + from importlib import import_module + self.models = import_module(f"{PACOTE}.models") + try: + self.normalizer = import_module(f"{PACOTE}.normalizer") + except ModuleNotFoundError: + self.skipTest("este produto não tem normalizer.py") + # O nome da função difere entre os irmãos; o que importa é o critério. + self.ler_do_normalizer = getattr(self.normalizer, "parse_iso_or_str_to_ms", None) \ + or getattr(self.normalizer, "parse_expiry_to_ms", None) + self.assertIsNotNone( + self.ler_do_normalizer, + "normalizer.py não expõe leitor de data com nome conhecido", + ) + + def test_o_mesmo_carimbo_vale_o_mesmo_instante_nos_dois(self): + for carimbo in CARIMBOS: + with self.subTest(carimbo=carimbo): + self.assertEqual( + self.ler_do_normalizer(carimbo), + self.models.to_epoch_ms(carimbo), + f"normalizer e models discordam sobre {carimbo!r}. Um decide " + f"apagar a trava de rate limit, o outro decide desenhá-la na " + f"tela -- e com leitores diferentes eles brigam.", + ) + + def test_carimbo_sem_fuso_e_lido_como_utc(self): + """O critério certo, explicitado: os gateways gravam em UTC. + + Assumir o fuso da máquina faria o mesmo dado significar horas diferentes + em dois servidores, o que é indefensável para um painel que compara + prazos. + """ + sem_fuso = self.models.to_epoch_ms("2026-09-12T15:20:22") + com_utc = self.models.to_epoch_ms("2026-09-12T15:20:22Z") + self.assertEqual( + sem_fuso, com_utc, + "carimbo sem fuso tem de valer o mesmo que o carimbo em UTC", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py index 48f9f71..aeef356 100644 --- a/tests/test_web_auth.py +++ b/tests/test_web_auth.py @@ -10,7 +10,7 @@ import unittest from nine_rtksync.config import Settings -from nine_rtksync.web.server import start_web_server +from nine_rtksync.web import start_web_server class TestWebAuth(unittest.TestCase): diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 10b8774..93bec4a 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -14,7 +14,7 @@ from nine_rtksync import i18n from nine_rtksync.config import Settings from nine_rtksync.models import ConnectionRecord -from nine_rtksync.web import render, server as web_server +from nine_rtksync import render, web as web_server # Faixas de emoji que não podem aparecer na interface (o padrão é fonte de ícones). EMOJI_PATTERN = re.compile( @@ -148,10 +148,10 @@ def _page(self, **overrides): cron={"active": True, "intervalSeconds": 300, "totalRuns": 4, "totalRenewals": 0, "lastRunAt": "2026-09-12T13:46:53Z", "nextRunAt": "2026-09-12T13:51:53Z", "lastResult": {"totalInspected": 7, "refreshedCount": 0, "durationMs": 5}}, - gateway={"url": "http://9router:20128", "online": True, "statusCode": 200, + gateway={"url": "http://9rtk-router:20128", "online": True, "statusCode": 200, "latencyMs": 9, "dbSummary": "Operacional (7 conexoes, 5 combos)"}, db_path="/app/data/db/data.sqlite", - router_url="http://9router:20128", + router_url="http://9rtk-router:20128", current_user="admin", is_default_password=True, refresh_margin=900, @@ -188,8 +188,12 @@ def test_secrets_are_never_rendered(self): def test_refresh_controls_are_present(self): page = self._page() - self.assertIn('action="/acoes/atualizar"', page) # botão Atualizar - self.assertIn('action="/acoes/sincronizar"', page) # Sincronizar agora + # "Atualizar" saiu da barra: recarregar e rodar o ciclo viraram um botão + # só ("Sync now"). A rota continua servida para quem a tenha salva. + self.assertIn('action="/logout"', page) # botão Sair + # Sincronizar dispara pelo agendador, para que a execucao manual + # apareca no historico junto com as automaticas. + self.assertIn('action="/acoes/cron"', page) # Sincronizar agora self.assertIn('action="/acoes/cron"', page) # Executar ciclo self.assertIn('action="/acoes/testar-gateway"', page) diff --git a/tests/test_web_resilience.py b/tests/test_web_resilience.py index c3cef9f..9eaebc9 100644 --- a/tests/test_web_resilience.py +++ b/tests/test_web_resilience.py @@ -10,7 +10,7 @@ import urllib.request from nine_rtksync.config import Settings -from nine_rtksync.web import server as web_server +from nine_rtksync import web as web_server class TestHealthzResilience(unittest.TestCase): @@ -45,7 +45,7 @@ def tearDownClass(cls): cls.tmp_dir.cleanup() def setUp(self): - web_server._router_probe_cache.clear() + web_server._gateway_probe_cache.clear() def test_server_is_multi_threaded(self): """Sem multi-thread, uma requisição lenta bloqueia o health check do Docker.""" @@ -115,7 +115,7 @@ class TestQuietHandleError(unittest.TestCase): """handle_error nao pode imprimir traceback quando o cliente apenas desconectou. Regressao do erro reportado em producao: - File ".../web/server.py", line 116, in serve_healthz + File ".../web.py", line 116, in serve_healthz self.wfile.write(b"OK") BrokenPipeError: [Errno 32] Broken pipe """ @@ -160,15 +160,15 @@ def write(self, _payload): self.assertTrue(handler.close_connection) -class TestRouterProbeCache(unittest.TestCase): +class TestGatewayProbeCache(unittest.TestCase): """A sondagem ao gateway não pode acontecer a cada probe: ela faz I/O de rede de até 3s.""" def setUp(self): - web_server._router_probe_cache.clear() + web_server._gateway_probe_cache.clear() self.calls = [] def tearDown(self): - web_server._router_probe_cache.clear() + web_server._gateway_probe_cache.clear() def _handler_with_fake_probe(self, url: str): calls = self.calls @@ -176,7 +176,7 @@ def _handler_with_fake_probe(self, url: str): class FakeHandler(web_server.DashboardHandler): router_url = url - def __init__(self): # não instancia socket: só exercita probe_router + def __init__(self): # não instancia socket: só exercita probe_gateway pass original_urlopen = web_server.urllib.request.urlopen @@ -202,28 +202,28 @@ def test_probe_result_is_cached(self): handler = self._handler_with_fake_probe("http://gateway.invalido:20128") for _ in range(10): - self.assertTrue(handler.probe_router()) + self.assertTrue(handler.probe_gateway()) # 10 chamadas ao /healthz, uma única ida à rede. self.assertEqual(len(self.calls), 1) def test_cache_expires_after_the_ttl(self): handler = self._handler_with_fake_probe("http://gateway.invalido:20128") - handler.probe_router() + handler.probe_gateway() # Envelhece a entrada de cache além do TTL. - cached_at, cached_ok = web_server._router_probe_cache["http://gateway.invalido:20128"] - web_server._router_probe_cache["http://gateway.invalido:20128"] = ( - cached_at - web_server.ROUTER_PROBE_TTL_SECONDS - 1, + cached_at, cached_ok = web_server._gateway_probe_cache["http://gateway.invalido:20128"] + web_server._gateway_probe_cache["http://gateway.invalido:20128"] = ( + cached_at - web_server.GATEWAY_PROBE_TTL_SECONDS - 1, cached_ok, ) - handler.probe_router() + handler.probe_gateway() self.assertEqual(len(self.calls), 2) - def test_no_router_url_means_no_network_call(self): + def test_no_gateway_url_means_no_network_call(self): handler = self._handler_with_fake_probe("") - self.assertTrue(handler.probe_router()) + self.assertTrue(handler.probe_gateway()) self.assertEqual(len(self.calls), 0) diff --git a/tests/test_web_security.py b/tests/test_web_security.py index 62456f4..f860b4a 100644 --- a/tests/test_web_security.py +++ b/tests/test_web_security.py @@ -19,7 +19,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): from nine_rtksync.config import Settings -from nine_rtksync.web import server as web_server +from nine_rtksync import web as web_server PORT = 19294 BASE = f"http://127.0.0.1:{PORT}" diff --git a/tools/measure_agent_usage.py b/tools/measure_agent_usage.py new file mode 100755 index 0000000..4a62c56 --- /dev/null +++ b/tools/measure_agent_usage.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Mede o perfil de consumo real de um agente a partir do historico do Claude Code. + +Le APENAS os campos numericos de `usage`, o `message.id` e o `timestamp`. +Nenhum conteudo de conversa e lido, agregado ou impresso. + +Cuidado que muda o resultado: uma unica resposta da API costuma ser gravada em +VARIAS linhas `type: "assistant"` (texto + cada bloco de ferramenta), todas com +o mesmo `message.id` e o mesmo objeto `usage`. Contar linhas infla requisicoes e +tokens em ~2x. Aqui cada `message.id` conta uma vez, e o proprio script imprime +a razao linhas/ids para que esse fator nao precise ser afirmado sem medida. + +O historico e um corpus VIVO: ele cresce a cada sessao, entao rodar sem recorte +da um resultado diferente a cada dia. Use `--until` para congelar a medicao num +instante e obter um numero reproduzivel bit a bit -- enquanto o Claude Code +mantiver os arquivos daquele periodo em disco. + +`--since` fecha o outro lado da janela. Os dois juntos servem para calibrar o +`C_window`: recorte o periodo da jornada, leia o TOTAL GERAL impresso no fim e +divida pela fracao consumida na tela de uso do fornecedor. +""" + +import argparse +import glob +import json +import os +import statistics +from datetime import datetime, timedelta, timezone + +HISTORY_ROOT = os.path.expanduser("~/.claude/projects") +IDLE_GAP = timedelta(minutes=20) # lacuna que encerra uma "hora ativa" +MIN_TURNS_PER_SESSION = 10 # sessao curta demais nao diz nada sobre ritmo +MIN_ACTIVE_SECONDS = 300 + + +def parse_cutoff(text): + """Converte o recorte ISO-8601 recebido na linha de comando. + + Os registros do historico sao lidos com fuso (o `Z` do timestamp vira + `+00:00`), entao o recorte tambem precisa ter fuso: comparar um instante + ingenuo com um instante com fuso levanta TypeError. + """ + moment = datetime.fromisoformat(str(text).replace("Z", "+00:00")) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment + + +def read_session(path, until=None, since=None): + """Devolve os turnos unicos de um arquivo de sessao, ordenados no tempo. + + Devolve tambem quantas LINHAS foram aceitas para chegar a esses turnos -- + mesma populacao, mesmos filtros -- porque e a razao entre as duas contagens + que mede a inflacao da duplicacao. + """ + turns = {} + lines_kept = 0 + try: + with open(path, "r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + if '"usage"' not in line: + continue + try: + record = json.loads(line) + except Exception: + continue + if record.get("type") != "assistant": + continue + message = record.get("message") or {} + usage = message.get("usage") or {} + if not isinstance(usage, dict): + continue + fresh_input = usage.get("input_tokens") or 0 + cache_write = usage.get("cache_creation_input_tokens") or 0 + cache_read = usage.get("cache_read_input_tokens") or 0 + output = usage.get("output_tokens") or 0 + if fresh_input + cache_write + cache_read + output <= 0: + continue + try: + when = datetime.fromisoformat( + str(record.get("timestamp")).replace("Z", "+00:00") + ) + except Exception: + continue + # Recorte: tudo depois do instante congelado fica de fora, para + # que a mesma linha de comando devolva o mesmo numero amanha. + if until is not None and when > until: + continue + if since is not None and when < since: + continue + lines_kept += 1 + # Deduplicacao: uma resposta da API = um message.id, nao uma linha. + # Entre as linhas que repetem o id, fica a de maior contagem: as + # primeiras podem trazer `output_tokens` ainda parcial. + message_id = message.get("id") or f"{path}:{when.isoformat()}" + candidate = (when, fresh_input + cache_write, output, cache_read) + previous = turns.get(message_id) + if previous is None or sum(candidate[1:]) > sum(previous[1:]): + turns[message_id] = candidate + except Exception: + return [], 0 + return sorted(turns.values(), key=lambda t: t[0]), lines_kept + + +def active_seconds(turns): + """Tempo em que o agente esteve de fato requisitando, ignorando pausas longas.""" + total = 0.0 + for previous, current in zip(turns, turns[1:]): + delta = current[0] - previous[0] + if timedelta(0) <= delta <= IDLE_GAP: + total += delta.total_seconds() + return total + + +def percentile(values, fraction): + ordered = sorted(values) + if not ordered: + return float("nan") + index = int(round(fraction * (len(ordered) - 1))) + return ordered[max(0, min(len(ordered) - 1, index))] + + +parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) +parser.add_argument( + "--until", + metavar="ISO8601", + default=None, + help="congela a medicao: ignora tudo com timestamp posterior a este instante. " + "Precisa de fuso, por exemplo 2026-09-12T22:00:00Z. Sem ele a medicao " + "varre o historico inteiro e muda a cada nova sessao.", +) +parser.add_argument( + "--since", + metavar="ISO8601", + default=None, + help="limite inferior da janela, mesmo formato do --until. Com os dois, a " + "medicao cobre so o periodo pedido -- e o recorte que calibra C_window.", +) +args = parser.parse_args() +cutoff = parse_cutoff(args.until) if args.until else None +floor = parse_cutoff(args.since) if args.since else None + +billable_input, output_tokens, cached_input, total_input = [], [], [], [] +request_rates, session_spans = [], [] +grand_billable = grand_output = grand_cached = grand_turns = grand_lines = 0 + +for session_path in glob.glob(os.path.join(HISTORY_ROOT, "**", "*.jsonl"), recursive=True): + turns, lines_kept = read_session(session_path, cutoff, floor) + if len(turns) < MIN_TURNS_PER_SESSION: + continue + elapsed = active_seconds(turns) + if elapsed < MIN_ACTIVE_SECONDS: + continue + count = len(turns) + billable_input.append(sum(t[1] for t in turns) / count) + output_tokens.append(sum(t[2] for t in turns) / count) + cached_input.append(sum(t[3] for t in turns) / count) + total_input.append(sum(t[1] + t[3] for t in turns) / count) + request_rates.append(count / (elapsed / 3600.0)) + session_spans.append((turns[0][0], turns[-1][0])) + grand_billable += sum(t[1] for t in turns) + grand_output += sum(t[2] for t in turns) + grand_cached += sum(t[3] for t in turns) + grand_turns += count + grand_lines += lines_kept + +print(f"recorte (--since) : {args.since or 'nenhum (desde o inicio)'}") +print(f"recorte (--until) : {args.until or 'nenhum (corpus vivo)'}") +print(f"sessoes analisadas : {len(request_rates)}") +print(f"linhas assistant com usage (antes do dedup) : {grand_lines}") +print(f"turnos unicos (dedup por message.id) : {grand_turns}") +inflation = f"{grand_lines / grand_turns:.2f}x" if grand_turns else "n/a" +print(f"inflacao de contar linha em vez de id : {inflation}") +print(f"T_in entrada que conta p/ ITPM mediana : {statistics.median(billable_input):.0f}") +print(f"T_in entrada que conta p/ ITPM p90 : {percentile(billable_input, 0.90):.0f}") +print(f"T_out saida mediana : {statistics.median(output_tokens):.0f}") +print(f"T_out saida p90 : {percentile(output_tokens, 0.90):.0f}") +print(f"T_cache leitura de cache mediana : {statistics.median(cached_input):.0f}") +print(f"T_tot entrada total (conta+cache) mediana : {statistics.median(total_input):.0f}") +print(f"T_tot entrada total (conta+cache) p90 : {percentile(total_input, 0.90):.0f}") +print(f"R_h requisicoes por hora ativa mediana : {statistics.median(request_rates):.0f}") +print(f"R_h requisicoes por hora ativa p90 : {percentile(request_rates, 0.90):.0f}") +total_all = grand_billable + grand_output + grand_cached +print(f"fracao de leitura de cache no total : {grand_cached / total_all * 100:.1f}%") +print(f"razao entrada total / entrada que conta : " + f"{statistics.median(total_input) / statistics.median(billable_input):.1f}x") + +# Concorrencia: quantas sessoes se sobrepoem no tempo. Numa maquina de um unico +# operador isto mede paralelismo de subagentes, nao concorrencia de time. +events = [] +for start, end in session_spans: + events.append((start, 1)) + events.append((end, -1)) +events.sort() +open_sessions = peak = 0 +for _, delta in events: + open_sessions += delta + peak = max(peak, open_sessions) +print(f"pico de sessoes simultaneas : {peak}") + +# Soma bruta do periodo -- e ela, e nao a mediana, que calibra C_window: +# recorte a jornada com --since/--until, leia a fracao p consumida na tela de uso +# do fornecedor e faca C_window ~= TOTAL GERAL / p. +print() +print(f"TOTAL GERAL entrada total (conta+cache) : {grand_billable + grand_cached}") +print(f"TOTAL GERAL saida : {grand_output}") +print(f"TOTAL GERAL token total do periodo : " + f"{grand_billable + grand_cached + grand_output}") diff --git a/tools/sizing.py b/tools/sizing.py new file mode 100755 index 0000000..bbea72d --- /dev/null +++ b/tools/sizing.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Resolve a formula de dimensionamento com os numeros medidos e arbitrados. + +Entradas medidas -- instantaneo congelado, reproduzivel pelo comando abaixo +nesta maquina, enquanto o Claude Code guardar os arquivos daquele periodo: + + python3 tools/measure_agent_usage.py --until 2026-09-13T02:00:00Z + +Entradas ARBITRADAS, nao medidas: `CONCURRENCY` e `SLACK`. Nao ha medicao por +tras delas -- sao valores escolhidos aqui para variar e mostrar a sensibilidade +da formula. Este script nao e fonte desses dois numeros; e o lugar onde eles +foram arbitrados. Meca os seus. +""" + +import math + +# --- medido: perfil de UMA sessao ativa de agente (recorte de 2026-09-13T02:00Z) --- +PROFILES = { + "mediana": {"rate_per_hour": 206, "billable_input": 3914, "output": 723, "total_input": 88442}, + "p90": {"rate_per_hour": 342, "billable_input": 7668, "output": 1351, "total_input": 205437}, +} + +# --- publicado: teto por tier da API, linhas Opus 5 / Sonnet 5 --- +# FONTE: https://platform.claude.com/docs/en/api/rate-limits - lido em 2026-09-12. +# Repare que isto e teto POR MINUTO (RPM/ITPM/OTPM), nao capacidade de janela: +# na API a pergunta e se a rajada estoura o limite por minuto, e nao quanto cabe +# num periodo de reset. Alem disso o ITPM da API ignora leitura de cache, o que +# o medidor de uma assinatura Pro/Max nao faz -- por isso as duas metades deste +# script usam unidades diferentes de proposito. +API_TIERS = { + "Start": {"rpm": 1_000, "itpm": 2_000_000, "otpm": 400_000}, + "Build": {"rpm": 5_000, "itpm": 5_000_000, "otpm": 1_000_000}, + "Scale": {"rpm": 10_000, "itpm": 10_000_000, "otpm": 2_000_000}, +} + +TEAM_SIZES = [3, 12, 40] +# ARBITRADO, nao medido: nao existe medicao de concorrencia por tras destes +# valores. Estao aqui para mostrar a sensibilidade da formula, e por isso sao +# tres. Quem citar este script como fonte de `c` esta citando um palpite. +CONCURRENCY = [0.4, 0.6, 1.0] +# ARBITRADO: folga operacional, decisao de quem opera, nao resultado de medicao. +SLACK = 0.30 +# PUBLICADO: janela de reset de 5 h da Anthropic. +# FONTE: https://support.claude.com/en/articles/11049741-what-is-the-max-plan +WINDOW_HOURS = 5 + + +def burst_demand(team_size, concurrency, profile, slack=SLACK): + """Demanda por minuto: e o teto que estoura primeiro.""" + simultaneous = team_size * concurrency + rpm = simultaneous * profile["rate_per_hour"] / 60 * (1 + slack) + return simultaneous, rpm, rpm * profile["billable_input"], rpm * profile["output"] + + +def smallest_tier(rpm, itpm, otpm): + for name, tier in API_TIERS.items(): + if rpm <= tier["rpm"] and itpm <= tier["itpm"] and otpm <= tier["otpm"]: + return name + return "acima de Scale" + + +print("### CAMINHO DE API - teto publicado, resolve numericamente") +for label, profile in PROFILES.items(): + print(f"\nperfil {label}: R_h={profile['rate_per_hour']} req/h, " + f"T_in={profile['billable_input']} tok/req, T_out={profile['output']} tok/req, " + f"folga={SLACK:.0%}") + print(f"{'devs':>5} {'c':>5} {'U_sim':>6} {'RPM':>7} {'ITPM':>10} {'OTPM':>9} tier minimo") + for size in TEAM_SIZES: + for factor in CONCURRENCY: + simultaneous, rpm, itpm, otpm = burst_demand(size, factor, profile) + print(f"{size:>5} {factor:>5.1f} {simultaneous:>6.1f} {rpm:>7.0f} " + f"{itpm:>10,.0f} {otpm:>9,.0f} {smallest_tier(rpm, itpm, otpm)}") + +print("\n\n### CAMINHO DE ASSINATURA (9Router) - C_janela e [A MEDIR]") +print("A unidade aqui e TOKEN TOTAL (entrada cacheada inclusa): o medidor da") +print("assinatura nao publica o que conta, e a calibracao de Settings > Usage") +print("so pode ser feita contra o total trafegado.\n") +print("D_total = U_sim * R_h * (T_tot + T_out) * W_h * (1 + F)") +for label, profile in PROFILES.items(): + for size in TEAM_SIZES: + factor = 0.6 + simultaneous = size * factor + demand = (simultaneous * profile["rate_per_hour"] + * (profile["total_input"] + profile["output"]) + * WINDOW_HOURS * (1 + SLACK)) + print(f"perfil {label:>7} | {size:>2} devs | c=0.6 | U_sim={simultaneous:4.1f} | " + f"W={WINDOW_HOURS}h | D = {demand:,.0f} tokens -> L = ceil(D / C_janela)") + +print("\n\n### folga da rajada contra UMA licenca de API no tier Start (1000 rpm)") +for size in TEAM_SIZES: + _, rpm, _, _ = burst_demand(size, 0.6, PROFILES["mediana"]) + print(f"{size:>2} devs, c=0.6 -> pico exigido {rpm:6.0f} rpm; " + f"Start cobre {math.floor(1000 / rpm)}x a demanda") diff --git a/tools/testa_saida_de_rede.sh b/tools/testa_saida_de_rede.sh index ed5a3af..04101a0 100755 --- a/tools/testa_saida_de_rede.sh +++ b/tools/testa_saida_de_rede.sh @@ -103,8 +103,8 @@ echo echo "-- 5. A PERGUNTA QUE IMPORTA: com o proxy fora do ar, o que acontece? --" echo " Se a requisicao ainda for atendida, ela saiu DIRETO -- pelo IP da" echo " maquina, com o token da conta. E o vazamento silencioso." -if docker ps --format '{{.Names}}' | grep -qx egress-proxy-b; then - docker stop egress-proxy-b >/dev/null 2>&1 +if docker ps --format '{{.Names}}' | grep -qx 9rtk-proxy-b; then + docker stop 9rtk-proxy-b >/dev/null 2>&1 sleep 2 visto=$(origem_vista "$PROXY_B" "/proxy-morto") if [ -z "$visto" ]; then @@ -115,10 +115,10 @@ if docker ps --format '{{.Names}}' | grep -qx egress-proxy-b; then else falha "proxy fora do ar -> a requisicao FALHA" "resposta inesperada de $visto" fi - docker start egress-proxy-b >/dev/null 2>&1 + docker start 9rtk-proxy-b >/dev/null 2>&1 sleep 3 else - falha "proxy fora do ar -> a requisicao FALHA" "egress-proxy-b nao esta na bancada" + falha "proxy fora do ar -> a requisicao FALHA" "9rtk-proxy-b nao esta na bancada" fi echo diff --git a/tools/valida_docs.py b/tools/valida_docs.py index 929ddc3..79cf03d 100644 --- a/tools/valida_docs.py +++ b/tools/valida_docs.py @@ -18,8 +18,9 @@ import os import re +import subprocess import sys -from typing import Dict, List, Set, Tuple +from typing import Dict, Iterable, List, Set, Tuple # -------------------------------------------------------------------------- # Extração da documentação @@ -34,8 +35,11 @@ # Linha que invoca outro programa: as flags citadas pertencem a ele. RX_COMANDO_DE_TERCEIRO = re.compile( + # `apk` é o gerenciador de pacotes do Alpine, e entra pela mesma razão do + # `apt`: a página de acesso federado mostra as linhas que instalam a + # biblioteca de SAML na imagem, e `--no-cache` e `--virtual` são flags DELE. r"\b(pip|pip3|docker|docker[- ]compose|git|curl|wget|tailscale|cloudflared|make|npm|npx|" - r"apt|apt-get|brew|systemctl|python3?\s+-m\s+venv|openssl|psql)\b" + r"apt|apt-get|apk|brew|systemctl|python3?\s+-m\s+venv|openssl|psql)\b" ) # Flags que pertencem a outro programa e aparecem em prosa, sem o comando na @@ -44,6 +48,12 @@ FLAGS_DE_TERCEIROS = { "--advertise-exit-node", "--exit-node", # tailscale "--help", "--version", # universais + # docker compose: a doc de acesso remoto explica como subir os servicos + # opcionais de tunel e tailnet, e essas flags sao do compose, nao do + # CLI deste produto. + "--profile", "--env-file", "--remove-orphans", "--wait", + "--wait-timeout", "--force-recreate", "--no-autoupdate", "--url", + "--token", "-d", "-f", } # Rotas do GATEWAY, nao do painel. A pagina de saida de rede cita a API do @@ -53,6 +63,26 @@ NAO_SAO_VARIAVEIS = { "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", # padrão do sistema, não do projeto } + +# Variáveis que pertencem a OUTRO programa e são citadas de propósito -- a +# contraparte de FLAGS_DE_TERCEIROS, pela mesma razão. A página de +# dimensionamento precisa nomear a flag que devolve o limitador legado do +# LiteLLM upstream, porque é esse o nome que o operador vai procurar na +# implantação dele; o proxy é outro programa, então o nome nunca vai aparecer +# no fonte deste repositório. +# +# Lista nomeada, e não padrão: cada entrada diz de onde veio, e uma variável +# nossa escrita errada continua sendo acusada. +VARIAVEIS_DE_TERCEIROS = { + # litellm/proxy/hooks/__init__.py:31, LiteLLM 1.102.0 + "LEGACY_MULTI_INSTANCE_RATE_LIMITING", + # Flag dos composes do 9RTKSync e do OminiRTkSync. A pagina de encadeamento + # precisa nomea-la porque ela e uma armadilha: quem le `REQUIRE_API_KEY=false` + # conclui que nao precisa de chave, enquanto o 9Router autoriza por peer e + # responde 401 a qualquer vizinho de rede. O nome nunca vai existir no fonte + # deste repositorio -- a flag e de outro programa. + "REQUIRE_API_KEY", +} # Siglas em caixa alta que aparecem em prosa e não são variáveis. RUIDO = re.compile( r"^(HTTP_?\d*|JSON_?\w*|API_?KEY_?\w*|SQL\w*|UTC_?\w*|README\w*|TODO\w*|NOTE\w*|" @@ -60,35 +90,92 @@ ) -def paginas(raiz: str) -> List[str]: - """Todo markdown versionado do repositório, menos os upstreams clonados.""" - achados = [] - for pasta, dirs, arquivos in os.walk(raiz): - dirs[:] = [ - d for d in dirs - if d not in (".git", "tmp", "node_modules", "__pycache__", ".venv", "assets") +# Diretórios que a varredura de emergência ignora. O `.git` e os upstreams +# clonados nunca são fonte; `assets` guarda binário. +PASTAS_IGNORADAS = {".git", "tmp", "node_modules", "__pycache__", ".venv", "assets"} + + +# Módulo Python citado na documentação: `render.py`, `client.py`. A crase é +# opcional porque tabela de arquitetura costuma escrever sem ela. +RX_MODULO = re.compile(r"\b([a-z_][a-z0-9_]*\.py)\b") + +# Módulos que pertencem a OUTRO projeto e são citados de propósito. Mesma razão +# de FLAGS_DE_TERCEIROS: o arquivo é real, só não é deste repositório. +MODULOS_DE_TERCEIROS = { + "setup.py", # convenção de empacotamento, citada em instruções de build + "manage.py", # Django, aparece em comparação de layout + "conftest.py", # pytest; pode ser citado como recomendação sem existir aqui + "proxy_server.py", # LiteLLM upstream + "main.py", # ponto de entrada do upstream em exemplos de implantação + # Os dois limitadores do LiteLLM upstream. A página de dimensionamento tem + # de nomeá-los porque é esse o arquivo que o operador vai procurar na + # implantação dele -- e ele nunca vai existir neste repositório. + "parallel_request_limiter.py", + "parallel_request_limiter_v3.py", +} + + +def arquivos_do_repo(raiz: str, extensoes: Tuple[str, ...]) -> List[str]: + """Arquivos do repositório com essas extensões — só os que são FONTE. + + Quem decide o que é fonte é o git, e não o disco: `--cached` traz o que está + versionado, `--others --exclude-standard` traz o que ainda não foi commitado + mas também não está ignorado (uma página de wiki recém-escrita, por exemplo), + e o `.gitignore` exclui sozinho o que é artefato de execução. + + Isso não é preciosismo. Rodar a suíte cria `.pytest_cache/README.md`, que + fala das flags `--lf` e `--ff` do pytest: varrendo o disco, o verificador + acusava o próprio cache de citar flags que este CLI não tem, e a suíte + passava ou falhava conforme já se tivesse rodado antes. + """ + try: + saida = subprocess.run( + ["git", "-C", raiz, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + capture_output=True, check=True, timeout=30, + ).stdout.decode("utf-8", errors="replace") + caminhos: Iterable[str] = (p for p in saida.split("\0") if p) + achados = [ + os.path.join(raiz, p) for p in caminhos + if p.endswith(extensoes) and not set(p.split(os.sep)) & PASTAS_IGNORADAS ] - for a in arquivos: - if a.endswith(".md"): - achados.append(os.path.join(pasta, a)) + except (OSError, subprocess.SubprocessError): + # Sem git disponível, cai para o disco. Aqui os diretórios ocultos + # também saem: é neles que moram os caches de ferramenta. + achados = [] + for pasta, dirs, arquivos in os.walk(raiz): + dirs[:] = [ + d for d in dirs + if d not in PASTAS_IGNORADAS and not (d.startswith(".") and d != ".github") + ] + achados += [os.path.join(pasta, a) for a in arquivos if a.endswith(extensoes)] return sorted(achados) +def paginas(raiz: str) -> List[str]: + """Todo markdown versionado do repositório, menos os upstreams clonados.""" + return arquivos_do_repo(raiz, (".md",)) + + +def modulos_do_repo(raiz: str) -> Set[str]: + """Nome de arquivo de todo módulo Python que existe aqui. + + Só o basename: a documentação cita `render.py`, não o caminho inteiro, e + quem lê quer saber se o arquivo existe, não onde exatamente ele mora. + """ + return {os.path.basename(c) for c in arquivos_do_repo(raiz, (".py",))} + + def fonte_do_repo(raiz: str) -> str: """Todo o código Python e YAML do repositório, concatenado.""" partes = [] - for pasta, dirs, arquivos in os.walk(raiz): - dirs[:] = [ - d for d in dirs - if d not in (".git", "tmp", "node_modules", "__pycache__", ".venv") - ] - for a in arquivos: - if a.endswith((".py", ".yml", ".yaml", ".toml", ".cfg", ".sh", ".example")): - try: - with open(os.path.join(pasta, a), encoding="utf-8") as f: - partes.append(f.read()) - except (OSError, UnicodeDecodeError): - pass + for caminho in arquivos_do_repo( + raiz, (".py", ".yml", ".yaml", ".toml", ".cfg", ".sh", ".example") + ): + try: + with open(caminho, encoding="utf-8") as f: + partes.append(f.read()) + except (OSError, UnicodeDecodeError): + pass return "\n".join(partes) @@ -127,6 +214,7 @@ def verificar(raiz: str, nome: str) -> List[str]: env_ok = env_do_codigo(fonte) flags_ok = flags_do_codigo(fonte) rotas_ok = rotas_do_codigo(fonte) + modulos_ok = modulos_do_repo(raiz) for pagina in paginas(raiz): rel = os.path.relpath(pagina, raiz) @@ -138,16 +226,35 @@ def verificar(raiz: str, nome: str) -> List[str]: for n, linha in enumerate(linhas, 1): for var in RX_ENV.findall(linha): - if var in NAO_SAO_VARIAVEIS or RUIDO.match(var): + if var in NAO_SAO_VARIAVEIS or var in VARIAVEIS_DE_TERCEIROS: + continue + if RUIDO.match(var): continue # Codigo de log, nao variavel: TCP_TUNNEL/200, HIER_DIRECT/1.2.3.4 # e NONE_NONE/000 aparecem em trecho de log colado na pagina, e # sempre com uma barra logo depois. if re.search(re.escape(var) + r"/", linha): continue + # Tag de log entre colchetes, tambem colada de saida real: + # `[SKILLS_INJECTION] {"apiKeyId":...}` no log do OmniRoute. E + # rotulo do proprio log, nunca variavel de ambiente. + if re.search(r"\[" + re.escape(var) + r"\]", linha): + continue if var not in env_ok: problemas.append(f"{nome}/{rel}:{n} variável citada e não usada no código: {var}") + # Módulo que a página descreve e que não existe mais. É o erro que + # a convergência dos irmãos produz em série: um módulo é fundido + # noutro, o código continua verde porque ninguém importa o nome + # velho, e a tabela de arquitetura segue descrevendo um arquivo + # apagado. Quem lê a wiki procura o arquivo e não acha. + for modulo in RX_MODULO.findall(linha): + if modulo in MODULOS_DE_TERCEIROS or modulo in modulos_ok: + continue + problemas.append( + f"{nome}/{rel}:{n} módulo citado e inexistente no repositório: {modulo}" + ) + # Uma linha que invoca outro programa traz as flags DELE. Acusar # `pip install --upgrade` de nao existir no nosso CLI e ruido, e # ruido treina o leitor a ignorar o verificador inteiro. diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..20d992e --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "9rtksync" +version = "1.0.0" +source = { editable = "." }