From 734748ea99630b848eb59c2caf126e7640159cc2 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 19:30:17 -0300 Subject: [PATCH 01/34] feat: identidade por produto, containers padronizados e o painel parando de assustar **Nomes.** Containers no padrao rtk-: 9rtk-router/9rtk-sync, ominirtk-router/ominirtk-sync, litellmrtk-db/router/sync, mais os da bancada. O nome do PROJETO passa a ser o do nosso produto; antes dois levavam o nome do gateway, o que fazia a stack parecer do upstream. Chave de servico, imagem, volume e URL interna ficaram intactas -- so container_name mudou, porque o DNS da rede resolve pelo servico. **Portas.** O claudegravity dos artigos fica com a 20128 (padrao do 9Router) e o painel dele saiu da 9091 para a 9190: a faixa 909x e das stacks dos repositorios. Agora o artigo e os tres sincronizadores sobem juntos, que e o caso de quem escreve o artigo com os projetos ao lado. **Permissao no volume compartilhado -- o gateway nao subia.** O sincronizador roda como root e cria db/ e logs/ no startup; esses diretorios nasciam com dono root e o gateway, que roda como `node` (1000), perdia a escrita no proprio volume. O sintoma era a tela de login do 9Router recusando com "EACCES: permission denied, mkdir /app/data/db/backups". O servico passa a declarar user: "1000:1000". Verificado subindo a stack do zero apos down -v. **Gateway recem-subido deixa de ser erro.** Enquanto ninguem cadastra a primeira conexao, o gateway nao criou o banco -- estado normal de quem acabou de instalar. O ciclo relatava `db_not_found` como falha, e o painel abria com "ERRO" em vermelho no primeiro minuto de uso. Pior que o susto: ensina o operador a ignorar o indicador de erro, que precisa continuar significando algo quando quebrar de verdade. Agora e um estado de espera anunciado, com o resumo na forma que a tela espera. Tres testes por repositorio. **Teste que quebrava na maquina de quem seguia a documentacao.** Settings carrega um .env do diretorio quando existe, e o fluxo documentado (`make setup`) cria exatamente esse arquivo -- a suite passava a ler a senha real de quem configurou a propria stack. Os testes de politica de senha agora apontam para um caminho inexistente, que e a forma de dizer "so o ambiente". **Identidade visual.** Cada sincronizador tem icone proprio, escolhido pelo que ele faz: raio (9RTKSync, o caminho rapido), bifurcacao (OminiRTkSync, distribui entre rotas) e medidor (LiteLlmRTKSync, confere teto). A marca deixou de ser gradiente de duas cores e virou icone branco sobre um tom do tema -- assim quem identifica o produto e a forma, e a cor fica por conta do tema. Favicon SVG embutido por produto: o /favicon.ico do painel responde 401, entao sem isso a aba ficava com o icone generico. **Grid.** A tabela de conexoes tem sete colunas e nenhuma largura declarada: o navegador dava a menor fatia justamente ao diagnostico, a coluna de frase mais longa, que quebrava em quatro linhas. Agora a divisao e declarada em com table-layout fixed, o identificador longo do provedor quebra dentro da celula, e em tela estreita a tabela rola em vez de espremer. **LiteLLM.** A UI dele aceita a MASTER_KEY como senha quando UI_USERNAME e UI_PASSWORD nao existem -- ou seja, para ver um painel o operador digitava a credencial que administra a instalacao inteira. As duas variaveis entraram nos composes. --- Makefile | 2 +- README.md | 4 +-- docker-compose.egress-test.yml | 8 ++--- docker-compose.example.yml | 10 ++++-- docker-compose.test.yml | 4 +-- docs/wiki/Authentication.md | 4 +-- docs/wiki/Egress-Testing.md | 14 ++++----- docs/wiki/Installation.md | 6 ++-- docs/wiki/Troubleshooting.md | 4 +-- src/nine_rtksync/daemon.py | 12 ++++++-- src/nine_rtksync/web/render.py | 46 ++++++++++++++++++++++++++-- tests/test_espera_pelo_gateway.py | 51 +++++++++++++++++++++++++++++++ tests/test_password_policy.py | 18 ++++++++--- tools/testa_saida_de_rede.sh | 8 ++--- 14 files changed, 153 insertions(+), 38 deletions(-) create mode 100644 tests/test_espera_pelo_gateway.py diff --git a/Makefile b/Makefile index 6583aa2..845c568 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ docker-build: docker build -t 9rtksync:latest -t ghcr.io/pathbit/9rtksync:latest . docker-run: - docker run --rm -it --name 9rtksync -p 9091:9090 9rtksync:latest + docker run --rm -it --name 9rtk-sync -p 9091:9090 9rtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/README.md b/README.md index 034286a..1d820bc 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Add `9rtksync` to your `docker-compose.yml` alongside [9Router](https://github.c services: 9router: image: decolua/9router:latest - container_name: claudegravity-router + container_name: 9rtk-router restart: unless-stopped ports: # 20128 dentro do container; 8081 no host, para nao disputar a porta @@ -156,7 +156,7 @@ services: 9rtksync: image: ghcr.io/pathbit/9rtksync:latest - container_name: 9rtksync + container_name: 9rtk-sync restart: unless-stopped ports: - "127.0.0.1:9091:9090" diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index c7a596b..7fc6f5c 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" diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 82a95fc..a3e8965 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -3,7 +3,7 @@ name: 9rtksync-stack services: 9router: image: decolua/9router:latest - container_name: 9router + container_name: 9rtk-router restart: unless-stopped ports: # Porta interna 20128 (padrao do 9Router); publicada em 8081 no host. @@ -29,8 +29,14 @@ services: - 9router_data:/app/data 9rtksync: + # 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 restart: unless-stopped ports: # Porta interna 9090 (igual no OminiRTKSync); publicada em 9091 no host. diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0ffbecb..1d3397a 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -14,7 +14,7 @@ name: 9rtksync-test services: 9router: image: decolua/9router:latest - container_name: 9rtksync-test-gateway + container_name: 9rtk-test-router restart: unless-stopped ports: - "127.0.0.1:19128:20128" @@ -46,7 +46,7 @@ services: 9rtksync: build: . image: ghcr.io/pathbit/9rtksync:local - container_name: 9rtksync-test-sync + container_name: 9rtk-test-sync restart: unless-stopped ports: # Porta interna 9090 nos tres sincronizadores; publicada em 19091 aqui. 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/Egress-Testing.md b/docs/wiki/Egress-Testing.md index ea1a33a..a0eb0ff 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_egress # 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/Installation.md b/docs/wiki/Installation.md index cadd218..12f64b9 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -15,12 +15,12 @@ docker pull ghcr.io/pathbit/9rtksync:latest A working `docker-compose.yml` alongside the gateway: ```yaml -name: 9router-stack +name: 9rtksync-stack services: 9router: image: decolua/9router:latest - container_name: 9router + container_name: 9rtk-router restart: unless-stopped ports: # 20128 dentro do container; 8081 no host, para nao disputar a porta @@ -35,7 +35,7 @@ services: 9rtksync: image: ghcr.io/pathbit/9rtksync:latest - container_name: 9rtksync + container_name: 9rtk-sync restart: unless-stopped ports: # Internal port 9090 (same in OminiRTKSync); published on 9091. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 04423dc..b806008 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -89,7 +89,7 @@ If the numbers still look wrong, the synchronizer may not be writing at all — Sign in with user `admin` and the **recovery hash** as the password. Find it with: ```bash -docker logs 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 ``` @@ -97,7 +97,7 @@ grep "Recovery hash" /app/data/logs/9rtksync.log If the log has already rotated past it, the value is on disk: ```bash -docker exec 9rtksync cat /app/data/.dashboard_recovery +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 diff --git a/src/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index 80d774a..480eb89 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -82,8 +82,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(), diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index 73117f2..645cab2 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/render.py @@ -172,6 +172,9 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: + + {esc(title)} @@ -350,12 +353,17 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {health_badge(c.health_status, lang)} {render_remaining(c, lang)} {render_last_refresh(c, lang)} - {esc(render_refresh_reason(c, refresh_margin, lang))} + {esc(render_refresh_reason(c, refresh_margin, lang))} """) return f"""
- +
+ + + + + @@ -675,8 +683,14 @@ def render_dashboard( padding: .15rem .5rem; font-family: var(--bs-font-monospace); font-size: .78rem; text-transform: uppercase; }} .table-dark {{ --bs-table-bg: transparent; --bs-table-border-color: var(--line); }} + /* A marca e icone BRANCO sobre um tom claro do proprio tema. O gradiente + de duas cores fazia as tres telas parecerem a mesma marca em cores + diferentes; com a forma do icone distinta e o fundo discreto, quem + identifica o produto e o desenho, e a cor fica por conta do tema. */ .brand-mark {{ width: 2.25rem; height: 2.25rem; display: grid; place-items: center; border-radius: .5rem; - background: linear-gradient(135deg, var(--brand-a), var(--brand-b)); color: #fff; font-size: 1.1rem; }} + background: color-mix(in srgb, var(--brand-b) 22%, transparent); + border: 1px solid color-mix(in srgb, var(--brand-b) 45%, transparent); + color: #fff; font-size: 1.15rem; }} .accordion-item, .accordion-button {{ background: var(--surface); color: var(--text); }} .accordion-button:not(.collapsed) {{ background: var(--surface-2); color: #fff; box-shadow: none; }} .cron-log {{ white-space: pre-wrap; word-break: break-word; font-size: .8rem; color: var(--text-dim); @@ -698,6 +712,32 @@ def render_dashboard( .barra-acoes .btn {{ height: 2rem; padding-top: 0; padding-bottom: 0; display: inline-flex; align-items: center; line-height: 1; }} .barra-acoes .fi {{ line-height: 1; }} + /* A tabela de conexoes tem sete colunas, e sem largura declarada o + navegador as reparte pelo conteudo: o diagnostico -- a coluna com a frase + mais longa -- recebia a menor fatia e quebrava em quatro linhas, enquanto + "Tipo" e "Status", de largura fixa, sobravam espaco. Declarar a divisao + resolve na origem, e `table-layout: fixed` faz o navegador respeita-la em + vez de recalcular pelo conteudo. */ + .tabela-conexoes {{ table-layout: fixed; }} + .tabela-conexoes th, .tabela-conexoes td {{ padding: .6rem .5rem; vertical-align: top; }} + .tabela-conexoes col.c-provedor {{ width: 9rem; }} + .tabela-conexoes col.c-nome {{ width: auto; }} + .tabela-conexoes col.c-tipo {{ width: 8rem; }} + .tabela-conexoes col.c-status {{ width: 6.5rem; }} + .tabela-conexoes col.c-validade {{ width: 10rem; }} + .tabela-conexoes col.c-renovacao {{ width: 12rem; }} + .tabela-conexoes col.c-diagnostico {{ width: 22rem; }} + /* O nome do provedor e um identificador longo e sem espaco + (openai-compatible-chat-ollama-local): sem isto ele estoura a coluna ou + forca a tabela a rolar horizontalmente inteira. */ + .tabela-conexoes .provider-chip {{ display: inline-block; max-width: 100%; + overflow-wrap: anywhere; white-space: normal; }} + .tabela-conexoes .diagnostico {{ overflow-wrap: anywhere; }} + /* Em tela estreita a tabela rola sozinha, em vez de espremer as colunas + ate o texto virar uma palavra por linha. */ + @media (max-width: 1200px) {{ + .tabela-conexoes {{ min-width: 68rem; }} + }} 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_password_policy.py b/tests/test_password_policy.py index fc3513b..7cc5ea4 100644 --- a/tests/test_password_policy.py +++ b/tests/test_password_policy.py @@ -160,6 +160,16 @@ def setUp(self): for chave in self.original: os.environ.pop(chave, None) + # self.settings() carrega um .env do diretorio atual quando existe, e o + # fluxo documentado (`make setup`) cria justamente esse arquivo. Sem apontar + # para um caminho inexistente, a suite passaria a ler a senha real de quem + # configurou a propria stack -- e falharia na maquina de quem seguiu a + # documentacao, que e o pior lugar para uma falha aparecer. + SEM_ARQUIVO = "/nao-existe/.env" + + def settings(self): + return Settings.from_env(self.SEM_ARQUIVO) + def restaurar(self): for chave, valor in self.original.items(): if valor is None: @@ -169,23 +179,23 @@ def restaurar(self): def test_only_the_user_set_is_not_env_managed(self): os.environ["DASHBOARD_USER"] = "admin" - self.assertFalse(Settings.from_env().dashboard_auth_from_env) + self.assertFalse(self.settings().dashboard_auth_from_env) def test_the_shape_shipped_in_the_compose_example_is_not_env_managed(self): # Exatamente o que o docker-compose.example.yml produz. os.environ["DASHBOARD_USER"] = "admin" os.environ["DASHBOARD_PASSWORD"] = "" - settings = Settings.from_env() + settings = self.settings() self.assertFalse(settings.dashboard_auth_from_env) # E o nome de usuario continua sendo respeitado. self.assertEqual(settings.dashboard_user, "admin") def test_a_real_password_is_env_managed(self): os.environ["DASHBOARD_PASSWORD"] = "Sample1!" - self.assertTrue(Settings.from_env().dashboard_auth_from_env) + self.assertTrue(self.settings().dashboard_auth_from_env) def test_nothing_set_is_not_env_managed(self): - self.assertFalse(Settings.from_env().dashboard_auth_from_env) + self.assertFalse(self.settings().dashboard_auth_from_env) if __name__ == "__main__": 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 From f080959537796a03b6d8e21f52c03adf9b5a8f2d Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 19:49:36 -0300 Subject: [PATCH 02/34] Isolar cada stack em rede, hostname e faixa de enderecos proprios Na rede default do mesmo daemon, duas stacks resolvem o mesmo nome curto: "http://9router:20128" apontava tanto para o gateway deste repositorio quanto para o da stack do artigo, e o painel nao tinha como dizer a qual deles estava conectado. Agora servico, container_name e hostname carregam o mesmo nome, e cada stack sobe na sua propria rede nomeada. Tres coisas que a renomeacao sozinha nao resolveria: - o .env fixava LITELLM_URL=http://litellm:4000, sobrepondo o default do compose -- era esse valor que aparecia no painel; - NEXT_PUBLIC_BASE_URL anunciava localhost:20128, que no host e a porta da stack do artigo, mandando o fluxo de login para o gateway errado; - as tres bancadas de egress nasceram com 172.31.0.0/24 e as mesmas portas, entao subir duas ao mesmo tempo falhava com "Pool overlaps". Cada repo passa a ter a sua faixa (172.31/172.32/172.33) e as suas portas. --- .env.example | 2 +- docker-compose.example.yml | 27 ++++++++--- src/nine_rtksync/i18n.py | 15 +++--- src/nine_rtksync/web/render.py | 86 +++++++++++++++++++++++++++++----- src/nine_rtksync/web/server.py | 8 +++- tests/test_web_render.py | 4 +- 6 files changed, 110 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 1ec29fa..837b3eb 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 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index a3e8965..03038a2 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,9 +1,12 @@ name: 9rtksync-stack services: - 9router: + 9rtk-router: image: decolua/9router:latest container_name: 9rtk-router + hostname: 9rtk-router + networks: + - 9rtksync-net restart: unless-stopped ports: # Porta interna 20128 (padrao do 9Router); publicada em 8081 no host. @@ -14,7 +17,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,7 +31,7 @@ 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) -- @@ -37,6 +40,9 @@ services: user: "1000:1000" image: ghcr.io/pathbit/9rtksync:latest 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. @@ -45,11 +51,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} @@ -67,11 +73,11 @@ services: # repassa-las ao container. - CREDENTIAL_CHECK_ENABLED=${CREDENTIAL_CHECK_ENABLED:-1} - CREDENTIAL_CHECK_TIMEOUT=${CREDENTIAL_CHECK_TIMEOUT:-8} - - LOG_DIR=${LOG_DIR:-/app/data/logs} + - LOG_DIR=${LOG_DIR:-/app/logs} - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - - 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 @@ -82,3 +88,10 @@ 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. diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py index 9af2a7c..b66f66c 100644 --- a/src/nine_rtksync/i18n.py +++ b/src/nine_rtksync/i18n.py @@ -126,11 +126,10 @@ "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.env_managed": "Credentials for this panel are managed outside it. Change them where the service is configured, then restart it.", "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", }, "pt": { @@ -240,11 +239,10 @@ "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.env_managed": "As credenciais deste painel são gerenciadas fora dele. Altere-as onde o serviço é configurado e reinicie-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", }, "es": { @@ -354,11 +352,10 @@ "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.env_managed": "Las credenciales de este panel se gestionan fuera de él. Cámbielas donde se configura el servicio y reinícielo.", "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", }, } diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index 645cab2..771a6bb 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/render.py @@ -293,6 +293,62 @@ def render_security_banner(is_default_password: bool, lang: str) -> str: """ + +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)) + + corpo = "".join( + f'
{esc(rotulo)}
' + f'
{valor}
' + for rotulo, valor in linhas + ) + 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}
    ') + + return f""" + """ + + def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: if not connections: return f""" @@ -310,6 +366,7 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: ) rows = [] + detalhes = [] for c in connections: if c.is_local: kind, kind_icon = translate("type.local", lang), "bi-hdd-network" @@ -353,8 +410,15 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang:
    - + """) + detalhes.append(render_connection_details(c, refresh_margin, compartilhando, lang)) return f"""
    @@ -362,7 +426,7 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang:
    - + @@ -372,13 +436,14 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: - + {"".join(rows)}
    {esc(translate("table.provider", lang))} {health_badge(c.health_status, lang)} {render_remaining(c, lang)} {render_last_refresh(c, lang)}{esc(render_refresh_reason(c, refresh_margin, lang))} + +
    {esc(translate("table.status", lang))} {esc(translate("table.remaining", lang))} {esc(translate("table.last_refresh", lang))}{esc(translate("table.diagnosis", lang))}{esc(translate("table.details", lang))}
    -
    """ + +{"".join(detalhes)}""" def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: @@ -487,11 +552,6 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: Logs {'!' if failed else ""} -
    - -
    @@ -672,7 +732,7 @@ def render_dashboard( --brand-a: #12806f; /* marca, inicio do gradiente */ --brand-b: #2fb8a4; /* marca, fim do gradiente */ --text: #e6e8ee; - --text-dim: #97a0b5; + --text-dim: #9fb8b3; }} body {{ background: var(--bg); color: var(--text); }} .card {{ background: var(--surface); border: 1px solid var(--line); }} @@ -701,7 +761,7 @@ def render_dashboard( .btn-primary {{ --bs-btn-bg: var(--accent); --bs-btn-border-color: var(--accent); --bs-btn-hover-bg: var(--accent-2); --bs-btn-hover-border-color: var(--accent-2); --bs-btn-active-bg: var(--accent-2); --bs-btn-active-border-color: var(--accent-2); - --bs-btn-color: #0b0d12; --bs-btn-hover-color: #0b0d12; --bs-btn-active-color: #0b0d12; }} + --bs-btn-color: var(--bg); --bs-btn-hover-color: var(--bg); --bs-btn-active-color: var(--bg); }} a {{ color: var(--accent-2); }} a:hover {{ color: var(--accent); }} /* Barra de acoes do cabecalho: todos os controles com a MESMA altura. O @@ -726,7 +786,7 @@ def render_dashboard( .tabela-conexoes col.c-status {{ width: 6.5rem; }} .tabela-conexoes col.c-validade {{ width: 10rem; }} .tabela-conexoes col.c-renovacao {{ width: 12rem; }} - .tabela-conexoes col.c-diagnostico {{ width: 22rem; }} + .tabela-conexoes col.c-detalhe {{ width: 5rem; }} /* O nome do provedor e um identificador longo e sem espaco (openai-compatible-chat-ollama-local): sem isto ele estoura a coluna ou forca a tabela a rolar horizontalmente inteira. */ @@ -767,7 +827,7 @@ def render_dashboard( -
    + diff --git a/src/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py index a82256f..13baf41 100644 --- a/src/nine_rtksync/web/server.py +++ b/src/nine_rtksync/web/server.py @@ -311,7 +311,13 @@ def handle_dashboard_action(self, route: str, raw_body: bytes) -> None: 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) + # Gravar pode falhar -- disco cheio, arquivo sem permissao de escrita. + # Redirecionar com sucesso nesse caso deixava o usuario 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", chosen): + self.redirect_to_dashboard("danger", translate("language.save_failed", chosen)) + return self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", "/") self.send_header("Content-Length", "0") diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 10b8774..2711fbc 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -189,7 +189,9 @@ 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 + # 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) From 57a2eec7953e4dc2f2951dabac1d4afbb57f1ed2 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 20:20:09 -0300 Subject: [PATCH 03/34] Nao marcar como invalida a credencial que nao conseguimos ler O gateway pode guardar a credencial cifrada em repouso, no formato enc:v1:::. Lendo o campo cru do banco, a sonda mandava o texto cifrado ao provedor, levava a recusa esperada e concluia "credencial invalida" -- e gravava isso de volta no banco do gateway. Medido no irmao OminiRTkSync: as cinco conexoes exibiam "invalid" e "Erro upstream HTTP 400" na tela do proprio gateway, inclusive uma cuja validade so venceria 13 minutos depois. Nada havia sido testado; o vermelho era nosso, e pedia ao operador exatamente a acao errada -- reautenticar uma conta que ninguem provou estar ruim. Aqui a mesma guarda entra por simetria: os dois sincronizadores compartilham este arquivo praticamente palavra por palavra, e o 9Router tambem cifra em repouso quando STORAGE_ENCRYPTION_KEY esta configurada. --- src/nine_rtksync/credential_check.py | 29 +++++++++++ tests/test_credencial_cifrada.py | 78 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/test_credencial_cifrada.py diff --git a/src/nine_rtksync/credential_check.py b/src/nine_rtksync/credential_check.py index b92b02b..bcdc1f7 100644 --- a/src/nine_rtksync/credential_check.py +++ b/src/nine_rtksync/credential_check.py @@ -255,6 +255,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 +295,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/tests/test_credencial_cifrada.py b/tests/test_credencial_cifrada.py new file mode 100644 index 0000000..8c21497 --- /dev/null +++ b/tests/test_credencial_cifrada.py @@ -0,0 +1,78 @@ +"""Credencial que não conseguimos ler não é credencial inválida. + +O gateway pode guardar a credencial cifrada em repouso (`enc:v1:::`). +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() From 01f72e84d85a024ee023ed49ddbd9412e485bc5a Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 20:24:51 -0300 Subject: [PATCH 04/34] Criar /app/logs na imagem com o dono que o compose usa Um volume nomeado montado sobre um diretorio que nao existe na imagem nasce com dono root. Como o sincronizador roda como uid 1000 (para dividir o volume do gateway sem estragar as permissoes dele), ele perdia a escrita do proprio log. Medido no irmao OminiRTkSync: "[Errno 13] Permission denied: '/app/logs/ominirtksync.log'". --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) 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 \ From 6aaebba276bd70fd8e4e61d6b440d257eaa503ac Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 21:51:49 -0300 Subject: [PATCH 05/34] Prender o painel do Makefile ao loopback, com guarda que impede o retorno O painel le o banco do gateway e mostra a saude das credenciais. Todos os composes deste repo publicam a porta dele em 127.0.0.1 e a wiki diz, em Remote-Access.md, para manter assim. Quem escapava era o Makefile: "-p 9091:9090" publica em TODA interface -- o Wi-Fi do cafe, a VLAN do escritorio. O LiteLlmRTKSync ja havia sido endurecido e os irmaos nao. A divergencia sobreviveu porque o teste que deveria pega-la nunca leu o Makefile: test_portas_documentadas.py varre so docker-compose*.yml e *.md. A guarda nova le o Makefile e reprova qualquer "-p" sem 127.0.0.1, alem de conferir que a porta publicada e a deste repositorio e nao a do irmao. Junto, duas divergencias que este repo nao recebeu no passe de isolamento: a rede da bancada de egress agora declara `name:` em vez de deixar o Docker deriva-lo do diretorio (os dois irmaos ja faziam), e o alvo do workflow de limpeza de pacotes apontava para "9rtksyncatest", nome que nao casa com imagem nenhuma -- a limpeza nunca limpou. 243 testes verdes. --- .github/workflows/cleanup-packages.yml | 2 +- Makefile | 7 ++- docker-compose.egress-test.yml | 3 ++ docs/wiki/Egress-Testing.md | 2 +- tests/test_makefile_publica_no_loopback.py | 51 ++++++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tests/test_makefile_publica_no_loopback.py 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/Makefile b/Makefile index 845c568..49bd2eb 100644 --- a/Makefile +++ b/Makefile @@ -52,8 +52,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 9rtk-sync -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/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index 7fc6f5c..352f5ff 100644 --- a/docker-compose.egress-test.yml +++ b/docker-compose.egress-test.yml @@ -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/docs/wiki/Egress-Testing.md b/docs/wiki/Egress-Testing.md index a0eb0ff..ff24469 100644 --- a/docs/wiki/Egress-Testing.md +++ b/docs/wiki/Egress-Testing.md @@ -57,7 +57,7 @@ the request: ```bash # 1. put the gateway on the bench network -docker network connect 9rtk-egress_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 \ 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() From 40dbf94a055d7cd6d4142b5fe8ab91c598e12b26 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 23:02:43 -0300 Subject: [PATCH 06/34] Guarda contra credencial de fabrica em texto de ajuda Espelha a guarda criada no OminiRTkSync, onde o --help anunciava "(padrao: pathbit)" em arquivo versionado. Aqui nao ha o defeito hoje; a guarda existe para que a proxima vez nao dependa de um cetico encontrar por acaso, procurando outra coisa. --- tests/test_ajuda_nao_publica_credencial.py | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/test_ajuda_nao_publica_credencial.py diff --git a/tests/test_ajuda_nao_publica_credencial.py b/tests/test_ajuda_nao_publica_credencial.py new file mode 100644 index 0000000..c1f7423 --- /dev/null +++ b/tests/test_ajuda_nao_publica_credencial.py @@ -0,0 +1,52 @@ +"""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" + +# Valores que já foram, em algum momento, senha de fábrica deste projeto ou +# dos gateways que ele acompanha. +CREDENCIAIS_DE_FABRICA = ("pathbit", "123456", "changeme", "admin123", "Pathbit@2026") + + +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", + ) + + +if __name__ == "__main__": + unittest.main() From b89c09f5f75f15f95411d8b340339ab9e71ca488 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 23:32:58 -0300 Subject: [PATCH 07/34] Doc de licencas x usuarios, com os scripts que reproduzem cada numero A pergunta "somos doze devs, quantas assinaturas eu compro?" nao tem resposta publicada: nenhuma das assinaturas de consumo declara a capacidade absoluta de um assento -- publicam multiplicador e janela. Uma tabela dizendo "1 licenca cobre 4 devs" so poderia sair de inventar o numero que ninguem divulga, e o numero inventado seria repetido por anos por quem nao tem como conferir. A pagina faz as tres coisas que dao para fazer com honestidade: da a formula com cada variavel nomeada, preenche o lado da demanda com o que foi medido, e da o comando que acha o divisor que falta na instalacao do leitor. Tres ceticos independentes reprovaram a primeira versao, e com razao. O que eles derrubaram, e que esta corrigido aqui: - a pagina afirmava que os scripts estavam versionados, e nenhum estava. Por isso este commit e conjunto: pagina e scripts juntos, ou a frase volta a ser falsa no instante seguinte. - numeros creditados a "contagem direta" -- que nao e comando nem script. Agora cada quantidade tem script que a reproduz, ou esta marcada como arbitrada, ou virou "meca assim: ". - o fator de concorrencia c=0,6 era citado como se viesse do script, sendo uma constante escolhida a dedo dentro dele. Fonte circular declarada como tal. - afirmacoes de schema que o proprio banco desmentia. - fonte apontando para caminho de disco de outro repositorio, que o leitor da wiki publicada nao tem. Um cetico de segunda passada refez os comandos de cada achado e confirmou a morte de cada um. Os que sobreviveram a essa segunda passada estao corrigidos neste commit. --- docs/wiki/Configuration.md | 6 +- docs/wiki/Dashboard.md | 13 +- docs/wiki/Home.md | 3 +- docs/wiki/Installation.md | 37 +- docs/wiki/Licensing-And-Capacity.md | 788 ++++++++++++++++++++++++++++ docs/wiki/Logging.md | 6 +- docs/wiki/Remote-Access.md | 21 +- docs/wiki/Troubleshooting.md | 9 +- docs/wiki/_Sidebar.md | 1 + tools/measure_agent_usage.py | 208 ++++++++ tools/sizing.py | 93 ++++ 11 files changed, 1153 insertions(+), 32 deletions(-) create mode 100644 docs/wiki/Licensing-And-Capacity.md create mode 100755 tools/measure_agent_usage.py create mode 100755 tools/sizing.py diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 1bdcf92..1d94189 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -39,7 +39,7 @@ out and produce `BrokenPipeError` in the logs. | `SYNC_INTERVAL` | `300` | Seconds between synchronization passes. | | `REFRESH_MARGIN` | `900` | Seconds of remaining validity below which a token is renewed. | | `CRON_INTERVAL` | inherits `SYNC_INTERVAL` | Dedicated interval for the scheduler, when you want it to differ from the sync pass. | -| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Run now** button, or `POST /api/sync`). | +| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Sync now** button, or `POST /api/sync`). | | `CREDENTIAL_CHECK_ENABLED` | `1` | Asks each provider whether the stored credential is still accepted. `0` turns the live check off and the panel falls back to reporting `Not checked`. | | `CREDENTIAL_CHECK_TIMEOUT` | `8` | Seconds allowed per credential probe. | @@ -109,11 +109,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..7e90250 100644 --- a/docs/wiki/Dashboard.md +++ b/docs/wiki/Dashboard.md @@ -15,8 +15,8 @@ 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. | | Resilience combos | Registered combos and their model cascade. | --- @@ -29,8 +29,7 @@ Every control is a real HTTP request that redirects back to the freshly rendered | Control | Effect | | :--- | :--- | | **Refresh** | Plain link to `/`; re-reads the database and re-renders. | -| **Sync now** | Runs a full synchronization pass, then reports what changed. | -| **Run now** | Triggers one scheduler cycle immediately. | +| **Sync now** | 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. | The page is served with `Cache-Control: no-store, must-revalidate`, so a browser reload always @@ -40,8 +39,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/Home.md b/docs/wiki/Home.md index 3702d7c..d3cef87 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -24,6 +24,7 @@ republishes these pages automatically. Editing a page directly here will be over | [Remote Access](Remote-Access) | Tunnel, Tailscale, and what has to be on before either | | [Egress Testing](Egress-Testing) | A bench that proves where the traffic actually leaves from | | [Egress and Multi-Session](Egress-And-Multi-Session) | Why several accounts sharing one outbound address is the risk, and what the gateway models | +| [Licensing and Capacity](Licensing-And-Capacity) | How many subscriptions for how many developers, 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 +85,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 12f64b9..d82dd5a 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -18,9 +18,12 @@ A working `docker-compose.yml` alongside the gateway: name: 9rtksync-stack services: - 9router: + 9rtk-router: image: decolua/9router:latest 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: 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..0e056b1 --- /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/database.py:26 — 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/server.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/server.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..a47b644 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. --- @@ -218,7 +221,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 +242,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/Troubleshooting.md b/docs/wiki/Troubleshooting.md index b806008..179f740 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 | | :--- | :--- | :--- | @@ -91,7 +92,7 @@ Sign in with user `admin` and the **recovery hash** as the password. Find it wit ```bash 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: @@ -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..2a4adce 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -8,6 +8,7 @@ - [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/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..0076c56 --- /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 (LiteLLM) - 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/OmniRoute) - 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") From 7fded495988a51f397d2bef9d0e19ebeb0a6d1ee Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 14:28:57 -0300 Subject: [PATCH 08/34] Contrato de identidade visual travado por teste "Os frontends devem ter os mesmos componentes, mas as cores mudam" so se sustenta se a diferenca entre os paineis estiver inteiramente nos VALORES de um conjunto fechado de tokens, nunca na existencia deles. A guarda fixa nove tokens de papel cromatico, que mudam por produto, e doze estruturais, identicos nos tres. Reprova token faltando, token sobrando, valor estrutural divergente, fundo trocado, dois papeis com a mesma cor e escada de profundidade fora de ordem. O espelho --brand-b = --accent fica declarado como intencional: ele existe nos tres de proposito, porque o gradiente da marca termina na cor de destaque. Colisao que existe nos tres e convencao; a que existe em um e descuido -- e foi assim que a guarda achou a marca do LiteLlm valendo o mesmo que a borda dele. --- tests/test_identidade_visual.py | 142 ++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/test_identidade_visual.py diff --git a/tests/test_identidade_visual.py b/tests/test_identidade_visual.py new file mode 100644 index 0000000..b9bc137 --- /dev/null +++ b/tests/test_identidade_visual.py @@ -0,0 +1,142 @@ +"""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. + +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 pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +RENDER = RAIZ / "src" / "nine_rtksync" / "web" / "render.py" + +# A cor de fundo deste produto, escolhida pelo dono. É o único token cujo valor +# este repositório tem autoridade para fixar. +FUNDO = "#091413" + +# 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", +} + +# 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. +ESTRUTURAIS = { + "--text": "#e6e8ee", + "--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)", +} + +CONTRATO = PAPEIS_CROMATICOS | set(ESTRUTURAIS) + + +def tokens_declarados(): + """Todos os tokens definidos no tema, com o valor de cada um.""" + fonte = RENDER.read_text(encoding="utf-8") + return { + m.group(1): m.group(2).strip() + for m in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;{}]+);", fonte) + } + + +class ContratoDeTokens(unittest.TestCase): + def test_declara_exatamente_os_tokens_do_contrato(self): + """Nem a menos (componente sem cor), nem a mais (cor que só um painel tem).""" + declarados = set(tokens_declarados()) + faltando = CONTRATO - declarados + sobrando = declarados - CONTRATO + self.assertEqual(faltando, set(), f"tokens do contrato ausentes: {sorted(faltando)}") + self.assertEqual( + sobrando, + set(), + f"tokens fora do contrato: {sorted(sobrando)} -- se a peça é legítima, " + "acrescente o token ao contrato dos TRÊS painéis, não só deste", + ) + + 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_declarados() + for token, esperado in ESTRUTURAIS.items(): + self.assertEqual( + declarados.get(token), + esperado, + f"{token} é estrutural: os três painéis declaram {esperado!r}", + ) + + def test_o_fundo_e_a_cor_deste_produto(self): + self.assertEqual( + tokens_declarados().get("--bg", "").lower(), + FUNDO.lower(), + "o fundo é a identidade do produto e foi escolhido pelo dono", + ) + + +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. + """ + declarados = tokens_declarados() + + # 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 cobre o resto sem dar falso positivo nele. + ESPELHOS_INTENCIONAIS = {frozenset({"--accent", "--brand-b"})} + + por_cor = {} + for token in PAPEIS_CROMATICOS: + valor = declarados.get(token, "").lower() + if valor.startswith("var("): # alias declarado de propósito + continue + por_cor.setdefault(valor, []).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.""" + declarados = tokens_declarados() + + def luz(token): + v = declarados.get(token, "").lstrip("#") + if len(v) != 6: + self.skipTest(f"{token} não é hexadecimal literal: {v!r}") + 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)), + ) + + +if __name__ == "__main__": + unittest.main() From d6e5ab35e385e7b465ef8e64c48332153b76e41c Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 15:12:02 -0300 Subject: [PATCH 09/34] Calar o cabecalho Server, e travar as duas simetrias por teste O cabecalho Server ia na primeira linha de toda resposta, inclusive no 401 que sai antes de qualquer autenticacao, logo acima da CSP e do X-Frame-Options que o resto do cabecalho instala -- a mesma resposta que fecha as portas dizia qual e a fechadura. Versao exata do interpretador e o que um scanner precisa para escolher o exploit certo, e nada no produto depende de publica-la. version_string() tambem e sobrescrito: o BaseHTTPRequestHandler concatena server_version + " " + sys_version, entao com sys_version vazio a resposta saia com um espaco sobrando no fim do valor. Junto, a guarda que reprova chave de traducao usada e nao declarada. Ela nao tinha defeito para achar aqui; entra por simetria, porque o irmao OminiRTkSync servia "egress.title" cru na tela e ninguem percebeu ate alguem abrir o painel. --- src/nine_rtksync/web/server.py | 16 ++++++++ tests/test_cabecalho_server.py | 61 ++++++++++++++++++++++++++++ tests/test_nenhuma_chave_crua.py | 70 ++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/test_cabecalho_server.py create mode 100644 tests/test_nenhuma_chave_crua.py diff --git a/src/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py index 13baf41..a51f0b5 100644 --- a/src/nine_rtksync/web/server.py +++ b/src/nine_rtksync/web/server.py @@ -50,6 +50,22 @@ def handle_error(self, request, client_address): class DashboardHandler(BaseHTTPRequestHandler): """HTTP handler serving dashboard UI, REST API, and cron scheduler with Basic Auth.""" + # O cabecalho Server ia na PRIMEIRA linha de toda resposta -- inclusive no + # 401, antes de qualquer autenticacao -- anunciando "BaseHTTP/0.6 + # Python/3.14.7", ou seja, a versao exata do interpretador, logo acima da + # CSP e do X-Frame-Options que o resto do cabecalho instala. Versao exata e + # o que um scanner precisa para escolher o exploit certo. + # + # version_string() tambem e sobrescrito porque o BaseHTTPRequestHandler + # concatena server_version + " " + sys_version: com sys_version vazio, a + # resposta sai com um espaco sobrando no fim do valor. + server_version = "9RTKSync" + sys_version = "" + + def version_string(self) -> str: + return self.server_version + + settings: Optional[Settings] = None sync_trigger_callback: Optional[Callable[[], Dict[str, Any]]] = None cron_scheduler: Optional[Any] = None diff --git a/tests/test_cabecalho_server.py b/tests/test_cabecalho_server.py new file mode 100644 index 0000000..351d6dd --- /dev/null +++ b/tests/test_cabecalho_server.py @@ -0,0 +1,61 @@ +"""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 re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +HANDLER = RAIZ / "src" / "nine_rtksync" / "web" / "server.py" + +PROIBIDO = ("python", "basehttp", "simplehttp", "wsgi") + + +class CabecalhoServerNaoDenuncia(unittest.TestCase): + def test_o_handler_declara_nome_proprio_e_versao_vazia(self): + fonte = HANDLER.read_text(encoding="utf-8") + self.assertRegex( + fonte, + r'server_version\s*=\s*"[^"]+"', + "sem server_version o padrão do BaseHTTP anuncia a versão do Python", + ) + self.assertRegex( + fonte, + r'sys_version\s*=\s*""', + "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): + fonte = HANDLER.read_text(encoding="utf-8") + achado = re.search(r'server_version\s*=\s*"([^"]+)"', fonte) + self.assertIsNotNone(achado) + nome = achado.group(1).lower() + for proibido in PROIBIDO: + self.assertNotIn( + proibido, + nome, + f"o nome anunciado ({achado.group(1)!r}) entrega a pilha", + ) + + +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() From 235c188e26ec369bac7aeb093eb784bc60f07fd2 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 15:26:59 -0300 Subject: [PATCH 10/34] Dar icone a aba do painel, que era a unica pagina sem ele O SVG do produto ja estava no modulo, mas so na pagina de aviso. O DASHBOARD -- a aba que o operador deixa aberta o dia inteiro, e a unica que ele realmente olha -- nao declarava icone nenhum, entao o navegador tentava /favicon.ico, levava 401 do Basic Auth e desenhava o quadrado generico. Com os tres sincronizadores abertos lado a lado, as tres abas ficavam indistinguiveis. O icone vira constante usada pelos dois documentos que o modulo serve, em vez de literal repetido -- duas copias e como elas divergem. Continua sendo data URI de proposito: qualquer URL seria buscada, e a busca leva 401 antes de o operador autenticar. A guarda conta os do modulo e exige uma declaracao de icone para cada um, entao uma pagina nova nasce com icone ou o teste reprova. --- src/nine_rtksync/web/render.py | 10 ++++-- tests/test_favicon.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/test_favicon.py diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index 771a6bb..f80b53c 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/render.py @@ -15,6 +15,11 @@ from ..i18n import DEFAULT_LANGUAGE, LANGUAGES, normalize_language, translate +# 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," + 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" @@ -172,10 +177,8 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: - - + {esc(title)} @@ -708,6 +711,7 @@ def render_dashboard( + 9RTKSync diff --git a/tests/test_favicon.py b/tests/test_favicon.py new file mode 100644 index 0000000..741b632 --- /dev/null +++ b/tests/test_favicon.py @@ -0,0 +1,56 @@ +"""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 unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +RENDER = RAIZ / "src" / "nine_rtksync" / "web" / "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.""" + # re.M porque assertRegex usa re.search sem flags, e `^` sozinho só + # casaria no primeiro caractere do arquivo. + self.assertIsNotNone( + re.search(r'^FAVICON = "data:image/svg\+xml,', self.fonte, re.M), + "o ícone tem de ser uma constante no topo do módulo", + ) + + 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.""" + achado = re.search(r'^FAVICON = "([^"]+)"', self.fonte, re.M) + self.assertIsNotNone(achado) + self.assertTrue( + achado.group(1).startswith("data:"), + "o ícone precisa ser data URI: qualquer URL seria buscada e levaria 401", + ) + + +if __name__ == "__main__": + unittest.main() From 7ff56cbf58254c40e1c5613739c886dd43957b44 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 15:47:43 -0300 Subject: [PATCH 11/34] Tela de login propria, e o grid que finalmente cabe na tela DUAS PORTAS PARA A MESMA CASA O painel nasceu so com Basic Auth, e o dialogo que o navegador abre para isso e janela DELE, nao pagina nossa: nao se traduz, nao se estiliza, nao tem logout -- a unica forma de sair era fechar o navegador -- e nao e HTML, entao qualquer ferramenta que dirija um navegador para ali, porque nao ha campo para preencher. Foi assim que a verificacao visual destes paineis travou. Agora existe um formulario, com a casca e a paleta de cada produto, que entrega um cookie assinado (HMAC, oito horas, HttpOnly, SameSite=Strict). O segredo nasce a cada processo, em memoria: reiniciar invalida as sessoes, que e a escolha certa para um painel que le credenciais. O Basic Auth continua aceito, porque e ele que faz curl e monitoramento funcionarem sem sessao -- quem pede HTML vai para o formulario, quem nao pede recebe o 401 de sempre. O nome do cookie carrega o prefixo do produto porque cookie nao se separa por porta: com nome generico, entrar num painel derrubaria a sessao dos outros dois no mesmo 127.0.0.1. O GRID QUE NAO CABIA Com table-layout:fixed a largura da coluna e lei, e text-nowrap sem overflow:hidden nao corta nem quebra: o excesso se desenha POR CIMA da coluna vizinha. Era por isso que a validade aparecia escrita sobre a data de renovacao. Tres mudancas, e so as tres juntas resolvem: - corte com reticencias, para o excesso parar na celula; - larguras redistribuidas: "Nome" tinha width:auto e engolia o espaco que sobrava, deixando "Chave de API" virar "Chave de ..." e o cabecalho "Detalhes" quebrar em "Detalhe" + "s"; - textos encurtados na celula, com a explicacao no modal: a data perde ano e segundos (13/09 18:44), e a validade perde o parentese. A coluna diz o fato; o botao (i) da linha diz a razao. No cartao do agendador, "Ultimo resultado" era a unica linha que empilhava o valor embaixo do rotulo, enquanto as duas acima punham o valor a direita; e a data completa nao cabia em col-6 e quebrava no meio de "UTC", virando "U C" na tela. O favicon tambem entra aqui: a pagina de login e a terceira que o modulo serve, e a guarda conta os para que nenhuma nasca sem icone. --- .env.example | 4 +- README.md | 49 ++++++++-- docker-compose.test.yml | 21 +++- src/nine_rtksync/i18n.py | 33 +++++++ src/nine_rtksync/sessao.py | 92 ++++++++++++++++++ src/nine_rtksync/web/render.py | 154 +++++++++++++++++++++++++----- src/nine_rtksync/web/server.py | 88 +++++++++++++++-- tests/test_egress_panel.py | 9 +- tests/test_portas_documentadas.py | 12 ++- tests/test_sessao.py | 68 +++++++++++++ tests/test_web_render.py | 4 +- tools/valida_docs.py | 10 +- uv.lock | 8 ++ 13 files changed, 498 insertions(+), 54 deletions(-) create mode 100644 src/nine_rtksync/sessao.py create mode 100644 tests/test_sessao.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example index 837b3eb..714980e 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -107,7 +107,7 @@ DASHBOARD_PASSWORD= # 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 diff --git a/README.md b/README.md index 1d820bc..4b68c32 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 @@ -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: 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: 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. ``` --- @@ -279,8 +312,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.test.yml b/docker-compose.test.yml index 1d3397a..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: 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: 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/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py index b66f66c..9b33d3e 100644 --- a/src/nine_rtksync/i18n.py +++ b/src/nine_rtksync/i18n.py @@ -44,8 +44,16 @@ "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.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", @@ -70,6 +78,7 @@ "table.status": "Status", "table.remaining": "Time remaining", "table.diagnosis": "Renewal diagnosis", + "table.details": "Details", "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,6 +87,7 @@ "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", @@ -104,6 +114,7 @@ "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", @@ -157,8 +168,16 @@ "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.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", @@ -183,6 +202,7 @@ "table.status": "Status", "table.remaining": "Validade restante", "table.diagnosis": "Diagnóstico da renovação", + "table.details": "Detalhes", "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", @@ -191,6 +211,7 @@ "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", @@ -217,6 +238,7 @@ "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}", @@ -270,8 +292,16 @@ "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.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", @@ -296,6 +326,7 @@ "table.status": "Estado", "table.remaining": "Validez restante", "table.diagnosis": "Diagnóstico de la renovación", + "table.details": "Detalles", "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", @@ -304,6 +335,7 @@ "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", @@ -330,6 +362,7 @@ "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}", diff --git a/src/nine_rtksync/sessao.py b/src/nine_rtksync/sessao.py new file mode 100644 index 0000000..60a0c3d --- /dev/null +++ b/src/nine_rtksync/sessao.py @@ -0,0 +1,92 @@ +"""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 + +NOME_DO_COOKIE = "9rtksync_sessao" + +# Oito horas: um turno de trabalho. Depois disso o operador entra de novo. +VALIDADE_EM_SEGUNDOS = 8 * 60 * 60 + +_SEGREDO = secrets.token_bytes(32) + + +def _assina(carga: str) -> str: + return hmac.new(_SEGREDO, carga.encode("utf-8"), hashlib.sha256).hexdigest() + + +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 + # compare_digest: a comparação não pode vazar, pelo tempo que leva, quantos + # caracteres do início bateram. + if not hmac.compare_digest(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_do_cabecalho(cabecalho_cookie: str) -> str: + """Extrai o valor do nosso cookie de um cabeçalho Cookie cru.""" + for parte in (cabecalho_cookie or "").split(";"): + nome, _, valor = parte.strip().partition("=") + if nome == NOME_DO_COOKIE: + return valor + return "" diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index f80b53c..143ef9d 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/render.py @@ -139,10 +139,10 @@ def render_last_refresh(conn: Any, lang: str) -> str: icon = '' detail = f'
    {esc(ago)}
    ' if ago else "" - return f'{icon}{esc(format_timestamp(stamp))}{detail}' + return f'{icon}{esc(format_timestamp_curto(stamp))}{detail}' -def render_remaining(conn: Any, lang: str) -> str: +def render_remaining(conn: Any, lang: str, curto: bool = False) -> str: """Validade restante, sem chamar de ilimitado o que so esta faltando. Um token OAuth sempre expira. Quando nao ha expiresAt legivel, isso e dado @@ -160,7 +160,25 @@ def render_remaining(conn: Any, lang: str) -> str: '' f'{esc(translate("duration.unknown_expiry", lang))}' ) - return f'{esc(translate("duration.no_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 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 render_notice_page(title: str, body: str, link_label: str = "") -> bytes: @@ -209,14 +227,14 @@ def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: if estado == "bound": pool = conn.egress_binding or "?" return ( - '' + '' f'' f'{esc(translate("egress.bound", lang))}: {esc(pool)}' ) if estado == "shared": if sharing_count > 1: return ( - '' + '' f'' f'{esc(translate("egress.shared", lang, count=sharing_count))}' ) @@ -232,6 +250,73 @@ def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: ) +def render_login_page(lang: str = DEFAULT_LANGUAGE, erro: str = "") -> 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) + aviso = ( + f'' + if erro + else "" + ) + return f""" + + + + + + + 9RTKSync + + + + + +
    +
    +

    + 9RTKSync +

    +

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

    + {aviso} + +
    + + +
    +
    + + +
    + + +
    +
    + +""".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"]) @@ -411,7 +496,7 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {esc(kind)} {health_badge(c.health_status, lang)} - {render_remaining(c, lang)} + {render_remaining(c, lang, curto=True)} {render_last_refresh(c, lang)}
    """ + + +def estado_vazio(mensagem: str) -> str: + """O bloco de estado vazio da familia: icone bi-inbox e uma frase. + + 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. + """ + return f""" +
    + + {esc(mensagem)} +
    """ + + +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 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 key_state_label(key: Any, lang: str) -> str: + """Se o gateway ainda aceita esta chave. + + O ``apiKeys`` do 9Router tem uma bandeira so, ``isActive``: nao existe aqui + a distincao entre revogada e banida que o irmao OminiRTkSync mostra, e + inventar os rotulos faria a tela prometer um dado que o banco nao guarda. + """ + if key.revoked: + 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))}'), + # O 9Router 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) -> str: + """Chaves virtuais emitidas pelo gateway, uma por linha, nas sete colunas.""" + if not keys: + return estado_vazio(translate("keys.empty", lang)) + + 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(keys): + 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 cabecalho_de_dominio(rows, 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: str = "ok") -> str: + """Modelos que o gateway publica, nas mesmas sete colunas dos irmaos. + + `estado` 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, "models.empty") + return estado_vazio(translate(motivo, lang)) + + visiveis = models[:MAX_LINHAS_DE_MODELO] + 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)) + + # Truncar em silencio seria mentir sobre o tamanho do catalogo. + rodape = "" + if len(models) > len(visiveis): + rodape = (f'

    ' + f'{esc(translate("models.showing", lang, shown=len(visiveis), total=len(models)))}

    ') + + return cabecalho_de_dominio(rows, lang) + rodape + "".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. @@ -449,46 +924,23 @@ def render_connection_details(conn: Any, refresh_margin: int, sharing_count: int if chip: linhas.append((translate("egress.title", lang), chip)) - corpo = "".join( - f'
    {esc(rotulo)}
    ' - f'
    {valor}
    ' - for rotulo, valor in linhas - ) 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}
    ') - return f""" - """ + extra = ( + f'

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

    ' + f'

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

    ' + f'{modelos}' + ) + return render_detail_modal(f"detalhe-{conn.id}", conn.name, linhas, lang, extra) def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: if not connections: - return f""" -
    - - {esc(translate("connections.empty", lang))} -
    """ + return estado_vazio(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 @@ -543,49 +995,16 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {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 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))}
    -
    -{"".join(detalhes)}""" + return cabecalho_de_dominio(rows, lang) + "".join(detalhes) def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: if not combos: - return f""" -
    - - {esc(translate("combos.empty", lang))} -
    """ + return estado_vazio(translate("combos.empty", lang)) rows = [] for combo in combos: @@ -793,6 +1212,12 @@ def render_dashboard( connections: List[Any], combos: List[Dict[str, Any]], cron: Dict[str, Any], + # Chaves virtuais e modelos entraram depois dos outros cartoes e sao + # opcionais na assinatura: quem chama sem eles (um teste antigo, um script) + # continua desenhando a pagina, com os dois cartoes no estado vazio. + keys: Optional[List[Any]] = None, + models: Optional[List[Any]] = None, + models_state: str = "ok", gateway: Dict[str, Any], db_path: str, router_url: str, @@ -802,9 +1227,15 @@ def render_dashboard( auth_from_env: bool = False, flash: Optional[Dict[str, str]] = None, lang: str = DEFAULT_LANGUAGE, + # Estado do SSO para a tela de configuracao. Opcional na assinatura pelo + # mesmo motivo de `keys` e `models`: quem chama sem ele -- um teste antigo, + # um script -- continua desenhando a pagina, com o modal no estado vazio. + sso_view: Optional[Dict[str, Any]] = None, ) -> str: """Monta a página completa do dashboard, já com todos os dados embutidos.""" lang = normalize_language(lang) + keys = keys or [] + models = models or [] 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") @@ -973,6 +1404,10 @@ def render_dashboard( {esc(translate("action.sync_now", lang))} +
    -
    - {esc(translate("combos.title", lang))} +
    + + {esc(translate("keys.title", lang))} + + {len(keys)} +
    + {render_keys_table(keys, lang)} +
    + +
    +
    + + {esc(translate("models.title", lang))} + + {len(models)} +
    + {render_models_table(models, lang, models_state)} +
    + +
    +
    + + {esc(translate("combos.title", lang))} + + {len(combos)}
    {render_combos_table(combos, lang)}
    @@ -1054,6 +1512,8 @@ def render_dashboard(
    + {render_sso_modal(sso_view, lang)} + ' - if desafio - 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, diff --git a/tests/test_protecao.py b/tests/test_protecao.py index 3a6e9f7..4442cc0 100644 --- a/tests/test_protecao.py +++ b/tests/test_protecao.py @@ -70,13 +70,6 @@ class ProvaDeTrabalho(unittest.TestCase): def setUp(self): protecao.limpa_apos_sucesso("10.0.0.4") - def _resolve(self, desafio): - alvo = "0" * protecao.DIFICULDADE - for n in range(5_000_000): - if hashlib.sha256(f"{desafio}{n}".encode()).hexdigest().startswith(alvo): - return str(n) - self.fail("desafio sem solução em tempo razoável") - 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): @@ -85,34 +78,37 @@ def test_so_e_exigido_depois_de_algumas_falhas(self): def test_resposta_correta_passa(self): desafio = protecao.novo_desafio() - self.assertTrue(protecao.resposta_confere(desafio, self._resolve(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, "0")) + 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() - resposta = self._resolve(desafio) - self.assertTrue(protecao.resposta_confere(desafio, resposta)) - self.assertFalse(protecao.resposta_confere(desafio, resposta)) + 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", "0")) + 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): - """Doze milissegundos não param um ataque distribuído por muitos endereços. - O teto por janela é por endereço, então quem tem mil máquinas nunca o - atinge. O que encarece esse ataque é a dificuldade subir com a insistência - de cada endereço -- sem cobrar nada de quem errou a senha uma vez. - """ +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") @@ -130,27 +126,17 @@ def test_insistir_encarece(self): ) def test_a_dificuldade_tem_teto(self): - """Sem teto, o navegador de um humano distraído travaria.""" 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_a_resposta_e_conferida_na_dificuldade_cobrada(self): - """Resolver o fácil e mandar onde se pede o difícil não pode passar.""" - desafio = protecao.novo_desafio() - alvo_facil = "0" * 3 - for n in range(200000): - if hashlib.sha256(f"{desafio}{n}".encode()).hexdigest().startswith(alvo_facil): - resposta_facil = str(n) - break - else: - self.skipTest("não achei solução fácil em tempo razoável") - resumo = hashlib.sha256(f"{desafio}{resposta_facil}".encode()).hexdigest() - if resumo.startswith("0" * 6): - self.skipTest("a solução fácil calhou de servir para a difícil") - self.assertFalse(protecao.resposta_confere(desafio, resposta_facil, 6)) + 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): From 36191414a26d74fc8ba64c6993e45a09e477063f Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Mon, 14 Sep 2026 13:00:02 -0300 Subject: [PATCH 34/34] chore(vscode): ignorar repositorios de tmp no source control do editor --- .vscode/settings.json | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) 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/**"] }