feat: identidade visual, convergência dos irmãos e desafio interativo de login - #10
Conversation
…ndo de assustar **Nomes.** Containers no padrao <prefixo>rtk-<papel>: 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 <colgroup> 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.
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.
O gateway pode guardar a credencial cifrada em repouso, no formato enc:v1:<iv>:<cifra>:<tag>. 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.
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'".
…orno 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.
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.
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: <comando>". - 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.
"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.
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.
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 <head> do modulo e exige uma declaracao de icone para cada um, entao uma pagina nova nasce com icone ou o teste reprova.
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 <head> para que nenhuma nasca sem icone.
…trabalho Preso ao loopback o painel nao precisa disso. Atras de um tunel precisa -- e o tunel e um botao que o operador aperta quando quiser, entao o freio tem de ja estar instalado quando ele apertar. Tres camadas, da mais barata para a mais cara: 1. Teto por janela. Mais de dez tentativas no mesmo endereco em cinco minutos devolve 429 com Retry-After. E o que para o script que tenta mil senhas por minuto. O teto e POR ENDERECO: contar global deixaria um unico atacante derrubar o acesso de todo mundo. 2. Espera que cresce. Cada falha seguida dobra o atraso da resposta seguinte, ate um teto -- sem teto, o proprio processo ficaria preso segurando respostas. O atraso e do lado do servidor: nao ha nada no cliente para desligar. Acertar a senha limpa a suspeita. 3. Prova de trabalho, depois de tres falhas. O navegador acha um numero cujo SHA-256 comeca com quatro zeros; conferir isso aqui e instantaneo. Sem conta, sem servico externo, sem cookie de rastreio e sem imagem para decifrar: o custo cai sobre quem tenta em massa, nao sobre quem esqueceu a senha. O desafio e de uso unico -- sem consumir, um bot resolveria uma vez e repetiria a resposta para sempre. Junto, robots.txt servido antes da sessao: um rastreador nao tem credencial, e a unica forma de ele ler a regra e ela nao exigir uma. Medido contra o painel no ar: dez tentativas com senha errada passam, a decima primeira responde 429.
A BARRA Ficou: idioma, "Sincronizar agora", "Sair" -- nesta ordem, todos com texto, nos tres paineis. "Atualizar" saiu porque recarregar a pagina e rodar o ciclo viraram um botao so: dois botoes vizinhos que parecem fazer a mesma coisa obrigam o operador a escolher no escuro. "Acesso" saiu da barra e a troca de senha foi para o rodape, ao lado de quem esta autenticado -- trocar a propria senha nao e uma acao de sincronizacao e nao pertencia ali. O cartao do agendador ganhou "Executar agora", que e de onde se quer forcar uma rodada de validacao sem subir ate o topo. O CARIMBO QUE SE ESTRAGAVA SOZINHO Na tela aparecia "2026-09-13 19:08:48 U C", e parecia defeito de CSS -- dava para jurar que a coluna era estreita e a palavra quebrava. Nao era: format_timestamp trocava TODO "T" da string por espaco. Aplicada uma vez acertava; aplicada sobre o proprio resultado, comia o "T" de "UTC". Agora troca apenas o separador da posicao 10, e o teste formata duas vezes para garantir que continue assim. O AVISO QUE NAO SUMIA O retorno de cada acao viaja na querystring (POST-Redirect-GET, para o F5 nao repetir a acao), e ficava: a URL guardava o texto e recarregar trazia de volta a mensagem de algo que ja tinha acontecido. Agora a querystring e limpa do historico assim que a pagina desenha, sem nova requisicao, e o aviso some sozinho depois de alguns segundos. Junto: o favicon passa a usar o MESMO glifo do Bootstrap Icons que a marca do cabecalho desenha, em vez de um SVG parecido feito a mao -- a aba e o painel eram reconheciveis como coisas diferentes. As colunas "Ultima renovacao" (que cortava o proprio titulo por sete pixels) e a do cartao do agendador ganharam a largura que o conteudo pede. 287 / 313 / 179 testes verdes.
O painel le o banco do gateway e mostra a saude das credenciais. Uma rota que escape da exigencia de sessao entrega isso a quem chegar -- e o jeito de uma rota escapar nao e alguem decidir abri-la, e alguem acrescentar uma rota nova e esquecer de protege-la. A guarda le o despacho do servidor e cobra o inverso do usual: toda rota e fechada, menos quatro, cada uma com o motivo escrito no proprio teste. /healthz porque o healthcheck do Docker roda sem credencial; /login porque exigir sessao para exibir o formulario que cria a sessao e um circulo fechado; /robots.txt porque um rastreador nao tem como autenticar e a regra so serve se ele conseguir le-la; e /credenciais-atualizadas porque e servida no instante seguinte a troca de senha, quando o navegador ainda guarda a anterior. Cobre tambem o POST, que muda estado, e confere que a sessao e exigida ANTES de o corpo ser lido -- ler primeiro e aceitar carga de quem nao entrou. Medido contra os tres paineis no ar: as treze rotas conhecidas respondem 401 sem credencial, exceto as quatro publicas.
Um cetico da rodada de integracao encontrou: a lista de "credenciais de
fabrica" do proprio teste trazia a senha REAL desta instalacao, escrita a
mao, e estava versionada nos tres repositorios. Um teste criado para
impedir credencial em arquivo versionado carregando uma -- o tipo de
ironia que passa despercebido por meses.
A lista agora so tem senhas de fabrica publicas dos gateways. O que
protege a senha de verdade e mais forte do que a lista era: o teste LE o
.env (gitignored) em tempo de execucao e varre tudo o que o git rastreia
atras daquele valor. Assim vale para a senha que o operador escolher,
qualquer que seja, e nao so para as que alguem lembrou de escrever aqui.
Varre o que o git rastreia, e nao o disco: e o que sai daqui quando
alguem clona ou publica. Onde nao ha git -- o conteiner de teste do
LiteLlm nao traz -- o teste pula em vez de medir a coisa errada, porque
sem a lista do que e versionado ele acusaria justamente o .env, que deve
mesmo conter a senha.
Junto: `make setup` passa a criar a rede de inferencia. Com external:
true o compose se recusa a subir enquanto ela nao existir, e a mensagem
("network declared as external, but could not be found") nao diz o que
fazer -- num clone limpo isso trava o primeiro up.
O dono nao conseguia conectar o LiteLLM ao 9Router nem ao OmniRoute. Eram duas causas, e a primeira foi criada por nos. REDE. Cada stack ficou na sua propria rede -- a pedido, porque antes duas stacks resolviam o mesmo nome curto e ninguem sabia a qual gateway o painel falava. O efeito colateral e que o LiteLLM nao resolvia nem o nome dos irmaos. Nao desfizemos o isolamento: criamos uma SEGUNDA rede, so de inferencia, a que os tres gateways se conectam alem da rede de gestao. Gestao isolada, trafego compartilhado. Medido depois: cada sincronizador continua enxergando apenas o gateway da propria stack. AUTENTICACAO. Os dois gateways exigem chave em /v1/*, apesar de REQUIRE_API_KEY=false -- o bypass de loopback nunca dispara sob Docker. O TESTE QUE MENTIA. A inferencia do OmniRoute responde 200 sem credencial nenhuma, entao um chat bem-sucedido ali nao prova que a chave vale. Ao recriar as stacks do zero as chaves morreram junto com os volumes, e o --testar aprovou o OmniRoute com credencial invalida enquanto acusava a master key do LiteLLM pelo 401 do 9Router. Agora a credencial e conferida ANTES, contra /v1/models, que e a rota que os dois protegem -- e a mensagem nomeia a chave recusada em vez de culpar a errada. Mais tres defeitos que so aparecem recomeçando do zero: - a credencial nomeada dentro do LiteLLM sobrevive ao volume do gateway, guardando a chave que morreu; passa a ser reescrita a cada execucao; - o id do modelo era fixo por gateway, entao renomear colidia e o proxy respondia "Failed to add model to db" sem dizer o que colidiu. O id deriva do nome; - o modelo de teste do 9Router usava Antigravity, que entra por OAuth e vive no volume: apagar o volume exige reconectar pela tela. Provedor por chave de API se recadastra por script, e por isso serve de teste. Prova, com tudo recriado do zero: 9Router responde PROVA-9ROUTER com `OUT 6` no log casando com `saida=6` da resposta; OmniRoute responde PROVA-OMNIROUTE com o POST registrado no mesmo instante. `make setup` cria a rede de inferencia, que e externa e por isso precisa existir antes do primeiro `up`.
Medido nesta maquina: 3 zeros custam 1 ms, 4 custam 12 ms, 5 custam 532 ms -- cada zero a mais multiplica o preco por dezesseis. Conferir custa 0,3 microssegundo em qualquer um deles, e e essa assimetria, de mais de quarenta mil vezes, que faz a prova de trabalho servir para alguma coisa. Doze milissegundos, porem, nao param um ataque DISTRIBUIDO. O teto por janela e por endereco, entao quem tem mil maquinas nunca o atinge, e cada uma paga doze milissegundos por tentativa -- barato demais. A dificuldade agora sobe um zero a cada bloco de falhas do mesmo endereco, com teto, porque sem teto o navegador de um humano distraido travaria. Quem acerta na segunda tentativa continua nao pagando nada: o desafio so aparece depois de tres falhas. O servidor cobra e o formulario anuncia a MESMA dificuldade. Fossem diferentes, o navegador resolveria um problema mais facil do que o cobrado e o login honesto passaria a falhar -- sem ninguem entender por que. Ha teste para isso: resolver o facil e mandar onde se pede o dificil nao passa. Visto no navegador, com o painel no ar: o desafio chega ao formulario, o navegador acha a resposta sozinho (13662, para o desafio dc1467e5...) e remove o aviso antes de a tela ser fotografada. O operador nao decifra imagem nem clica em semaforo -- ele so espera um instante que nao chega a perceber. 295 / 321 / 187 testes verdes.
O DEFEITO: sair e digitar / voltava logado. O Basic Auth nao tem logout. O navegador guarda a credencial e a reenvia sozinho ate a janela fechar, e NAO existe cabecalho que mande ele esquecer -- e uma limitacao do esquema, nao um descuido de quem o usa. Enquanto a navegacao aceitava as duas portas, "Sair" apagava o cookie e a visita seguinte entrava pela outra. O botao mentia. A correcao separa por QUEM esta batendo, e nao por qual rota: pede text/html (navegador) -> exige SESSAO, e so nao pede html (curl, cron) -> Basic Auth, como antes Assim o logout volta a significar alguma coisa para quem tem uma tela na frente, e continua nao significando nada para uma ferramenta -- que e o certo, porque ferramenta nao "sai". Medido nos tres: navegador com Basic Auth agora recebe 302 para /login, enquanto curl na API continua recebendo 200. JUNTO, duas coisas que o dono pediu na mesma conversa: - rota que nao existe responde 404 em qualquer porta, logada ou nao. Antes ela devolvia 302 para /login sem sessao: um convite a fazer login para depois descobrir que a pagina nunca existiu. "Voce precisa entrar" e "isso nao existe" sao respostas diferentes para perguntas diferentes. - rota REAL sem sessao continua redirecionando para /login, em qualquer porta -- isso ja funcionava e segue funcionando. E o cartao "Coerencia dos limites" sai da tela do LiteLlm: era a unica peca que os irmaos nao tinham, e a regra da casa e que os tres mostrem a mesma estrutura. Os achados continuam sendo apurados pelo ciclo e contados no cartao de metrica. 295 / 321 / 187 testes verdes.
OS CARTOES Os tres paineis mostravam conjuntos diferentes: os irmaos tinham conexoes e combos, o LiteLlm tinha chaves virtuais e modelos, e nenhum tinha tudo. Agora os seis existem nos tres, nesta ordem: gateway, agendador, conexoes monitoradas, chaves virtuais, modelos cadastrados, combos de resiliencia. A regra que decidiu os casos dificeis: o cartao EXISTE sempre; o que muda e o conteudo. Onde o gateway nao tem o conceito, o cartao aparece com o estado vazio dizendo por que esta vazio NAQUELE produto -- estado vazio honesto vale mais que assimetria. Duas decisoes que valem registro: - no 9RTKSync o catalogo vem de HTTP, e nao do banco como no irmao: o 9Router nao persiste catalogo nenhum (tabela kv vazia, PROVIDER_MODELS e JS estatico), entao /v1/models e a unica fonte. A chave que autoriza essa leitura e a que o proprio gateway emitiu, vira cabecalho e morre ali -- nao e renderizada, nao vai a log, nao passa por argv; - no LiteLlm "conexoes monitoradas" sao os destinos reais por tras dos modelos, agrupados por (provedor, api_base), e o status da linha e o PIOR veredito entre os modelos daquele destino. A alternativa -- tratar o proprio proxy como unica conexao -- diria sempre a mesma coisa e nao ajudaria a descobrir qual provedor parou. O SSO Botao "Configuracoes" no cabecalho, antes de "Sair", abrindo a tela com as duas abas. OIDC com PKCE S256 sempre (o code passa pela barra de enderecos e fica no historico; state e nonce sozinhos nao bastam), state, nonce, e a validacao que muita implementacao esquece: o issuer devolvido pela descoberta tem de ser IDENTICO ao configurado, que e a defesa contra mix-up de provedor. OIDC nao precisou de dependencia nenhuma -- urllib ja verifica TLS de verdade na imagem (119 CAs). SAML2 precisou, e isso esta dito em vez de disfarcado: xml.etree canonicaliza em C14N 2.0 enquanto SAML assina sobre Exclusive C14N 1.0, nao ha verificacao RSA na stdlib, e xml.etree nao se defende de XML Signature Wrapping -- o ataque especifico deste protocolo. Onde cada coisa mora, por sensibilidade: o que e publico (issuer, client_id, certificado do IdP) vai para o SQLite de preferencias; o segredo do cliente vai para arquivo 0600, como a credencial de recuperacao ja faz, e o ambiente vence o arquivo. Nada de cifra caseira num banco cuja chave ficaria ao lado -- isso e teatro, e a imagem nem tem biblioteca de criptografia. O SSO e OPCIONAL e ha caminho de volta: sem configuracao o painel e o de hoje, SSO_DISABLED=1 desliga pelo ambiente sem tocar no banco, o formulario de senha nunca sai da tela, e a porta nao-HTML (Basic Auth para curl e monitoramento) nunca passa por SSO. 383 / 390 / 283 testes verdes (eram 295 / 321 / 187).
O CANONE Os tres sincronizadores devem ser quase clones: o que muda e cor, nome, logo e a quem cada um se conecta. Esta rodada moveu isso para dois arquivos e fundiu o resto. - `identidade.py` passa a ser o UNICO lugar onde nome de produto, nome de gateway, paleta, icone, portas e prefixo de container podem aparecer; - `gateway.py` passa a ser o unico lugar que sabe de que gateway se trata. No 9RTKSync ele absorveu `database.py` (renomeado) e o catalogo HTTP; no OminiRTkSync absorveu `database.py`, `discovery.py`, `providers.py`, `daemon.py` e `combos.py`; no LiteLlmRTKSync absorveu `client.py`, `engine.py` e `limits.py`. Seis modulos deixaram de existir. Isso nao e limpeza cosmetica: enquanto cada produto tivesse o seu proprio arquivo para a mesma ideia, cada agente escrevia a ideia do seu jeito, e foi assim que os tres chegaram a mil linhas de diferenca em codigo que deveria ser o mesmo. O card "Coerencia dos limites" saiu tambem do LiteLlmRTKSync, onde tinha sobrado como funcao orfa -- definida e nunca chamada. Era o ultimo resto do que voce mandou tirar de todas as aplicacoes. A CATRACA MEDIA COM UMA TRENA QUE MUDAVA DE TAMANHO A guarda instalada na rodada passada tinha um defeito serio. O difflib indexa a SEGUNDA sequencia, entao o mesmo par de arquivos dava numero diferente conforme quem media: o sso.py acusava 1354 linhas medido do OminiRTkSync e 1388 medido do LiteLlmRTKSync. Na pratica a suite do 9RTKSync passava e a do LiteLlmRTKSync reprovava com os arquivos identicos, e eu quase afastei os tetos acreditando que o codigo tinha piorado. Nao tinha. Agora a medida e feita em ordem canonica -- o par sempre na direcao dada por IRMAOS, nunca "eu contra ele". Desligar o autojunk nao resolvia: testei, continua assimetrico (1368 contra 1312 no mesmo par). A catraca foi provada por mutacao, e nao por ficar verde: com cinco linhas injetadas num sso.py os tres reprovam, e ao reverter os tres voltam a passar. A versao anterior passava com 1121 linhas de divergencia e nunca tinha falhado uma vez -- um teste assim nao provou nada. Divergencia de hoje, em ordem canonica: i18n 503, models 680, render 1633, sso 1354, web 1954. Total 6124 linhas, e cada numero so pode descer. A SUITE NAO PODE DEPENDER DE `pip install -e` O OminiRTkSync parou de coletar com ModuleNotFoundError. A causa nao estava no codigo: so o LiteLlmRTKSync declarava pythonpath, e o 9RTKSync passava por acidente -- alguem tinha rodado `pip install -e` naquele repo um dia e a instalacao editavel ficou na maquina. O irmao sem ela nem chegava a rodar. Os tres agora declaram testpaths e pythonpath. A DOCUMENTACAO DESCREVIA ARQUIVOS APAGADOS Fundir modulo deixa a wiki falando de arquivo que nao existe mais, e nada quebra: o codigo segue verde porque ninguem importa o nome velho. Quem descobre e o leitor, procurando um arquivo que nao esta la. `tools/valida_docs.py` ganhou a quarta verificacao -- modulo citado tem de existir -- e passou a ser o mesmo arquivo nos tres (o do LiteLlmRTKSync era o mais completo: usa `git ls-files` em vez de varrer o disco). Ele acusou 20 referencias mortas no LiteLlmRTKSync e 3 no OminiRTkSync que nenhuma suite pegava. As citacoes [FONTE: arquivo:linha] da pagina de dimensionamento nao foram so renomeadas: cada uma foi reapontada para onde o codigo realmente foi parar, conferindo o texto da linha antiga contra o arquivo novo. E o traceback de exemplo do Troubleshooting apontava para `web/server.py`, que nao existe -- agora aponta para web.py:236. 403 / 412 / 297 testes verdes, e zero referencias mortas nos tres.
`tools/sizing.py` imprimia "CAMINHO DE API (LiteLLM)" e "CAMINHO DE ASSINATURA (9Router/OmniRoute)". Os dois caminhos sao conceitos reais -- pagar por API contra usar assinatura -- mas nomear ali o gateway dos irmaos faz o leitor concluir que precisa deles para dimensionar este. O OminiRTkSync ja imprimia so "(OmniRoute)". Agora os tres falam do proprio gateway e de mais nenhum. Conferido rodando o script: a tabela sai igual, so o cabecalho mudou.
A stack inteira segue a regra, menos o container do Tailscale. E de proposito: o hostname dele vira o NOME DO NO na tailnet, que e o endereco que voce digita no navegador. Com o nome longo viraria `http://9rtk-tailnet:9090` em vez de `http://9rtk:9090` -- mais para digitar, sem ganho, porque dentro da stack ninguem alcanca esse container pelo nome: ele existe para expor o painel para FORA. Sem esse comentario, a proxima pessoa que passar os olhos no compose "corrige" a inconsistencia e quebra o endereco que todo mundo ja decorou. Os tres composes seguem validos (`docker compose config`).
O i18n.py estava a doze linhas de convergir, e as doze eram legitimas: o
9Router e o OmniRoute RENOVAM credencial OAuth, enquanto o LiteLLM apenas
INSPECIONA -- nao ha OAuth para renovar la. Chamar os tres de "Agendador
de renovacao" deixaria um deles mentindo na tela, e por isso a diferenca
tinha resistido a rodada inteira de convergencia.
Divergencia legitima nao se resolve escolhendo um dos textos. Se resolve
mudando de lugar: `cron.title` e `cron.result_line` sairam do catalogo e
foram para `ROTULOS_DO_PRODUTO`, em identidade.py -- que e justamente o
arquivo onde mora o que muda de produto para produto. O `translate()`
consulta o rotulo do produto antes do catalogo comum.
Resultado: os tres i18n.py agora tem o MESMO md5. Ele sai da lista de
tetos e entra em IDENTICOS, onde a comparacao e byte a byte, sem
normalizacao nenhuma. Sao oito modulos identicos agora: auth,
credential_check, i18n, logs, paginacao, prefs, protecao e sessao.
O contrato de identidade.py, que tests/test_identidade_visual.py fecha
("exatamente estes nomes, nem um a mais"), ganhou o nome novo. Foi esse
teste que reprovou a primeira tentativa -- ele estava certo: um simbolo a
mais em identidade.py e uma decisao de produto, e decisao de produto se
declara.
Divergencia total: 2045 linhas, de 6124 no inicio da rodada.
Achado por um cetico lendo o diff, nao por teste -- nenhum cobria isto. O `models.py` ganhou nesta rodada um leitor proprio, `to_epoch_ms`, que le carimbo ISO SEM fuso como UTC. Esta certo: e assim que os gateways gravam, e assumir o fuso da maquina faria o mesmo dado significar horas diferentes em dois servidores. O que ninguem percebeu e que o `normalizer.py` continuou vivo, lendo O MESMO CAMPO com o criterio antigo -- `datetime.fromisoformat(...).timestamp()` sem tzinfo, que o Python interpreta como fuso local. Medido nesta maquina (-03): "2026-09-12T15:20:22" valia 1789237222000 para o normalizer e 1789226422000 para o models. Tres horas. Nao era diferenca academica. O normalizer e quem decide APAGAR a trava de rate limit quando ela venceu (normalize_connection_data), e o models e quem decide MOSTRAR "rate_limited" na tela. Com leitores que discordam, o painel podia desenhar uma trava que o proprio sincronizador ja tinha considerado vencida -- ou o sincronizador apagar uma que ainda valia. O normalizer passa a usar o criterio do models. Os dois agora devolvem o mesmo instante para ISO sem fuso, ISO com Z, ISO com deslocamento, epoch em ms como texto, epoch em ms numerico e epoch em segundos. E agora ha teste: `test_um_leitor_de_data_so.py` cobra que qualquer carimbo valha o mesmo nos dois modulos. Provado por mutacao, e nao por ficar verde -- desfazendo so a linha que fixa o fuso, ele reprova exatamente no carimbo sem fuso; revertendo, volta a passar. No LiteLlmRTKSync ele pula, porque aquele produto nao tem normalizer.
`RegisteredModelRecord.name` caia para `self.id`, e `id` devolve string vazia quando o gateway nao traz nem `model_info.id` nem `id`. Nesse caso a linha aparecia no grid com a coluna de nome VAZIA -- o operador lia uma linha sem saber a que cadastro ela se refere. Agora cai para travessao, que e o sinal que o resto do painel ja usa para "nao declarado". DUAS LICOES DESTE COMMIT, QUE VALEM MAIS QUE ELE A primeira: o buraco foi achado por um cetico lendo o diff, e nao por teste. Ele veio de uma rodada de convergencia que trocou o fallback antigo por `self.id` sem repararem que `id` pode ser vazio. A segunda: corrigi primeiro SO no LiteLlmRTKSync, e a catraca de convergencia reprovou na hora -- models.py saltou de 7 para 18 linhas de divergencia. Ela estava certa: o buraco era identico nos tres, entao a correcao tambem tinha de ser. Foi exatamente para isso que a catraca foi instalada, e e a primeira vez que ela pega um erro meu em vez de um erro de agente.
Achado por um agente que conferiu o README do OminiRTkSync contra o codigo, e que se recusou a copiar a afirmacao do irmao por desconfiar dela. Estava certo. O README do 9RTKSync dizia que o cartao de diagnostico faz `POST /api/test-gateway` e que a troca de senha faz `POST /api/change-password`. As duas rotas existem (web.py:400 e :402), mas NAO e por elas que os botoes passam: o formulario do painel aponta para `/acoes/testar-gateway` (render.py:1245), que responde com redirecionamento -- POST-Redirect-GET, para que recarregar a pagina nao repita a acao. Nomear so a API fazia o leitor concluir que o botao a usava. Agora o texto descreve as duas: os botoes pelo `/acoes/`, e as rotas `/api/` para curl e monitoramento, que respondem JSON. As duas existem de proposito. E AS DUAS SECOES QUE TINHAM FICADO EM PORTUGUES `### Configuração: .env a partir do exemplo` e `### Portas, e por que cada uma e diferente` estavam em portugues dentro de um README em ingles, no 9RTKSync e no LiteLlmRTKSync -- resto de uma rodada anterior. O agente traduziu as do OminiRTkSync e avisou que os irmaos tinham as mesmas; traduzidas agora, com o mesmo texto nos tres. Fica de fora, de proposito: o bloco `> **Português:**` do aviso de login, que e bilingue nos tres por decisao, e a secao `# Em português` no fim do README do LiteLlmRTKSync, que e um resumo a mais e nao um resto -- tirar conteudo util nao e convergir. `tools/valida_docs.py`: 0 divergencias nos tres.
Outro achado de cetico lendo o diff. `sso.base_url_help`, que e a ajuda do CAMPO de endereco publico, tinha adotado na rodada de convergencia um texto que ja falava do tunel rapido trocar de endereco a cada subida -- exatamente o que `sso.tunnel_warning` diz no alerta, no MESMO modal. O operador lia a mesma advertencia duas vezes, uma embaixo da outra. O alerta fica com o aviso do tunel, que e o lugar dele. A ajuda volta a falar do campo, e recupera a frase que a convergencia tinha comido: o endereco de retorno nasce DESTE valor, e nunca dos cabecalhos da requisicao. Essa frase nao e enfeite: ela documenta a defesa contra um provedor de identidade ser induzido a mandar o codigo de volta para outro lugar por um cabecalho Host forjado. O comportamento sempre esteve correto -- `sso.py` monta o redirect_uri a partir de `config['base_url']`, e isso nao foi tocado --, mas quem lia a tela tinha deixado de saber disso. Nas tres linguas, e o i18n.py segue com o mesmo md5 nos tres produtos.
…caiu Terceira regressao achada por cetico, e a pior das tres. `web.py` le o catalogo com `try: models = [...list_models()...] except Exception: models = []`. Com o gateway fora do ar, a excecao era engolida e a lista vazia ia direto para a tela, que mostrava "o gateway respondeu com o catalogo de modelos vazio". Ou seja: a afirmacao era exatamente o contrario do que tinha acontecido, e o operador ia procurar um cadastro faltando em vez de olhar o gateway. Os dois irmaos nao tinham esse problema porque o render deles recebe `estado_do_catalogo`, com ramos para `no_key` e `unreachable`. O do LiteLlm nao recebia. As chaves de traducao ja existiam nos tres (o i18n.py e byte a byte identico), so ninguem as usava aqui. Agora `render_models_table` do LiteLlm recebe o mesmo parametro, e o `web.py` marca `unreachable` quando a leitura levanta. Provado em execucao: com "ok" a tela diz catalogo vazio, com "unreachable" diz que o gateway nao respondeu, com "no_key" explica que falta chave ativa. DUAS VOLTAS ATE ACERTAR, E A CATRACA DEU AS DUAS Primeiro inventei um parametro `estado_do_catalogo` no `render_dashboard` -- e o `models_state` ja existia ali, posto pelos agentes justamente para que a MESMA assinatura sirva aos tres `web.py`. A catraca acusou: render 870 -> 876, web 759 -> 769. Troquei pelo nome que ja existia e o render caiu para 862, abaixo do teto. Sobraram seis linhas no `web.py`, e o teto subiu de 759 para 765 -- a unica vez nesta rodada. A razao esta escrita no proprio teste, que e o que a catraca exige: nos irmaos o estado nasce em `gateway.py` e chega pronto em duas linhas; aqui a leitura acontece no `web.py`, entao sao quatro. Fechar isso de vez e mover a leitura para `gateway.py`, e fica registrado como aberto. 420 / 427 / 297 testes verdes. Divergencia total 1956 linhas, de 6124.
…sito O pedido foi "nos footers coloque algo assim em tudo", com a imagem "Feito com (coracao) pela Pathbit - All rights reserved (c) 2026". Um agente ja tinha feito no painel; faltava nas telas de ANTES do login, que sao justamente as que alguem ve sem estar autenticado -- e a de login nao tinha rodape nenhum. Agora sai de uma funcao so, usada pela tela de entrada, pela de login e pelo painel. O ano vem de `datetime.now().year`: um ano escrito a mao fica errado em primeiro de janeiro e ninguem revisa rodape. O coracao e `bi-heart-fill` e nao o emoji, porque o cabecalho do render fixa "Bootstrap Icons, nunca emoji" -- e emoji muda de desenho conforme o sistema de quem olha. A TELA QUE FICOU DE FORA, E POR QUE A pagina de aviso NAO leva a assinatura. Ela nao e tela do produto: e o corpo das respostas 401 e 429, servidas a quem ainda nao entrou. E a assinatura traz "Pathbit" e o ano na mesma linha -- que sao exatamente os dois pedacos da senha deste projeto. Isso nao foi deducao minha: ao ligar o rodape, o teste `test_the_401_body_never_teaches_the_credentials` reprovou na hora. Ele proibe "pathbit", "password" e "senha" no corpo do 401, e existe porque um dia houve ali um banner que ensinava a credencial. A saida certa nao era afrouxar aquele teste -- era nao dar a ele nada para encontrar. TESTE NOVO `test_rodape_em_toda_tela.py` CONTA a assinatura em cada tela, e nao apenas procura: quando o rodape virou funcao, a versao escrita a mao continuou no painel por um momento e as duas apareceram juntas. "Existe" nao bastava; "existe uma vez" basta. Ele tambem cobra que o ano seja o do relogio e que o coracao nao seja emoji, e prova que a pagina de aviso segue limpa. 424 / 431 / 301 testes verdes.
DOIS BURACOS NA PAGINACAO, E O PRIMEIRO ESTAVA NA TELA
`paginacao.py` chama traduzir("pagination.range") e
traduzir("pagination.label"), e NENHUMA DAS DUAS existia no catalogo. O
`translate` devolve a propria chave quando nao acha -- entao a barra que
os quatro grids ganharam nesta rodada estava desenhando o texto literal
"pagination.range" onde deveria dizer "1-10 de 47", e o <nav> anunciava
"pagination.label" para quem usa leitor de tela.
Nenhum teste pegou porque nenhum olhava o texto renderizado da barra: a
paginacao funcionava, so nao dava para ler. Agora as duas chaves existem
nas tres linguas, e a barra diz "1-10 of 47" / "1-10 de 47".
O HISTORICO DO AGENDADOR, QUE FICOU DE FORA
O pedido foi "Todos os grids, de logs tambem por favor que abre em
popups". Quatro grids ganharam paginacao -- conexoes, chaves, modelos e
combos -- e o historico do agendador, que e justamente o log que abre em
popup, continuou desenhando a lista inteira.
Aqui a paginacao e no CLIENTE, com jQuery, e nao no servidor como nos
outros quatro. A razao e o modal: virar pagina pelo servidor recarregaria
o documento, e recarregar FECHA o modal -- o operador clicaria em
"proxima" e a janela sumiria. Foi voce mesmo que apontou essa direcao ao
reclamar da querystring: "pq nao usar o jquery ou javascript?".
Provado com 23 ciclos: tres paginas, contagem dizendo 23 (o TOTAL, e nao
o tamanho da pagina), e com 6 ciclos a barra some sozinha.
Faltam ainda dois grids do pedido original: a rota /logs esta declarada
em ROTAS_CONHECIDAS sem handler nenhum -- ela nao serve nada hoje --, e o
mesmo vale para /favicon.ico. Ficam registrados.
424 / 431 / 301 testes verdes.
O `sso.py` dos tres ja trazia a federacao inteira -- `saml_disponivel`,
`url_de_ida_saml` e `processa_resposta_saml` existem em todos desde a
rodada de convergencia. O que so existia no LiteLlmRTKSync eram as
ROTAS. Dois produtos ficaram com o codigo completo e nenhuma porta para
chegar nele, e foi um agente que percebeu e se recusou a esconder:
deixou escrito no proprio codigo que nao oferecia o provedor na tela
"porque desenharia um botao para uma rota que responde 404".
Agora `/sso/saml/iniciar`, `/sso/saml/acs` e `/sso/saml/metadata` existem
nos tres, com os mesmos tres handlers portados do canone. O `metadata`
fica atras do login, como la.
DUAS GUARDAS DE SEGURANCA REPROVARAM, E ESTAVAM CERTAS
Ao ligar as rotas, `test_nada_sem_login.py` reprovou duas vezes: a ida ao
provedor e servida ANTES do require_auth, e o ACS e um POST sem sessao.
As duas coisas sao verdade e sao necessarias -- quem vai ao provedor
ainda nao tem sessao, e quem posta no ACS e o PROVEDOR, que nao carrega
cookie deste painel. O teste nao aceita isso calado: exige que a rota
publica esteja numa lista COM o motivo escrito. Os motivos foram
escritos.
O teste que afirmava a AUSENCIA do SAML foi invertido, nao apagado: ele
documentava um estado intermediario deliberado ("rota que existe e sempre
recusa e pior que rota que nao existe") e agora cobra a presenca das
tres.
TESTE NOVO: SIMETRIA DE ROTAS
`test_rotas_simetricas.py` exige que os tres sirvam EXATAMENTE o mesmo
conjunto de rotas, nos dois sentidos -- sobrar tambem e divergir. Provado
por mutacao: removendo uma rota do OminiRTkSync, a suite do 9RTKSync
reprova apontando o irmao; revertendo, volta a passar.
Ele le so onde rota e DECLARADA (o conjunto ROTAS_CONHECIDAS e as
comparacoes do despacho), e nao qualquer literal do arquivo: pegar
qualquer "/..." faria um `log("acesso a /logs negado")` virar rota.
EFEITO COLATERAL BOM: a divergencia do web.py caiu de 765 para 689
linhas. Convergir de verdade e mais barato que remendar.
O QUE AINDA NAO ESTA FEITO, E FICA DITO: a TELA do 9RTKSync e do
OminiRTkSync ainda nao OFERECE o SAML. O nucleo e as rotas existem, mas
`ler_configuracao` desses dois nao le os campos do IdP -- e a camada de
configuracao deles e justamente a parte do `sso.py` que nao convergiu.
426 / 433 / 303 testes verdes. Divergencia total 1886 linhas, de 6124.
`/favicon.ico` e `/logs` estavam em ROTAS_CONHECIDAS -- a lista que decide entre servir e devolver 404 -- e nenhuma das duas tinha handler. Quem pedia caia no despacho generico e recebia uma PAGINA HTML. Medido subindo o painel numa porta efemera: 200 com `text/html` para as duas. Um navegador pedindo o icone da aba recebia HTML. `/favicon.ico` agora responde **204 No Content**, e e servida ANTES da exigencia de sessao porque o navegador pede o icone tambem na tela de login. O icone de verdade vem de um data URI embutido no HTML, de proposito: um arquivo servido ficaria atras da autenticacao e a aba ficaria sem icone ate o operador entrar. Mas 404 faria o navegador registrar erro em toda visita -- 204 encerra sem corpo e sem erro. `/logs` foi removida da lista. Nada linka para ela, nenhuma pagina da wiki a cita, e nao havia handler: virou promessa vazia em algum refactor. Agora responde 404, que e a verdade. O log que o dono pediu paginado e o do agendador, que abre em popup e ja pagina de dez em dez. Provado nos tres, por HTTP contra o servidor de verdade: /favicon.ico=204 /logs=404 /nao-existe=404 426 / 433 / 303 testes verdes.
…nti-robo Substitui a prova de trabalho (PoW) baseada em loop assincrono de SHA-256 no navegador por um desafio interativo direto de selecao com 4 opcoes. - Elimina o travamento de CPU e o spinner de espera passiva no login. - O operador humano resolve a verificacao com 1 clique direto em 1 segundo. - Mantem protecao contra ataques em massa: scripts HTTP sem interface sao rejeitados instantaneamente e robos com IA sofrem custo e latencia de visao. - Ajusta a espera progressiva por falha para teto maximo de 5s. - Atualiza catalogo de traducoes (i18n) e testes unitarios em simetria.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3bc2f30b84
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| conteudo = render_dashboard( | ||
| sso_view=self.sso_view(), |
There was a problem hiding this comment.
Pass the dashboard query into the renderer
When any grid contains more than ten records, clicking a generated pagination link such as ?pag_modelos=2 still renders page 1 because serve_dashboard() parses the query but never supplies it as consulta to render_dashboard(). Consequently, every table is permanently limited to its first ten entries through the actual HTTP flow; pass the parsed query through this call.
Useful? React with 👍 / 👎.
| if not _assinatura_confere(assinatura, _assina(carga)): | ||
| return None | ||
| if "|" not in carga: | ||
| return None | ||
| usuario, _, expira = carga.rpartition("|") | ||
| try: | ||
| if float(expira) < (agora if agora is not None else time.time()): | ||
| return None | ||
| except ValueError: | ||
| return None | ||
| return usuario or None |
There was a problem hiding this comment.
Invalidate existing sessions after credential changes
When an operator changes the dashboard username or password, every previously issued browser cookie remains accepted until its eight-hour expiry because validation checks only the process-wide signing secret and timestamp, never the current credential state. This is especially problematic when a password is rotated after suspected cookie or account compromise: the old session retains full panel access. Rotate the session signing generation or bind cookies to an authentication revision whenever either credential-update endpoint succeeds.
Useful? React with 👍 / 👎.
| if com_desafio and protecao.precisa_de_desafio(endereco): | ||
| desafio = protecao.novo_desafio() | ||
| dificuldade = protecao.dificuldade_para(endereco) |
There was a problem hiding this comment.
Create challenges with the computed difficulty
After repeated failures increase dificuldade_para() to five or six, this code still calls novo_desafio() without that value, so the stored challenge always contains the default four options. Passing the later-computed number to the renderer does not add options, and resposta_confere() ignores its dificuldade argument, meaning the advertised escalation never takes effect. Compute the difficulty first and pass it into novo_desafio(quantidade=...).
Useful? React with 👍 / 👎.
| with _trava: | ||
| _desafios[desafio_id] = { | ||
| "criado_em": time.time(), | ||
| "alvo": alvo, | ||
| "opcoes": [item[0] for item in escolhidos], | ||
| } |
There was a problem hiding this comment.
Bound and expire challenges during challenge creation
Once an address has accumulated three failures, every unauthenticated GET /login creates another entry in _desafios, but novo_desafio() neither invokes _limpa() nor enforces a size limit. Since login-page GETs are not rate-limited and cleanup currently runs only through registra_tentativa() on a subsequent POST, a client can continuously request the login page and grow this process-wide dictionary without bound. Expire old entries and impose a hard cap while creating challenges.
Useful? React with 👍 / 👎.
| - TS_SERVE_CONFIG=/config/serve.json | ||
| volumes: | ||
| - 9rtk_tailnet:/var/lib/tailscale |
There was a problem hiding this comment.
Mount the Tailscale serve configuration
When the new tailnet profile is enabled, the container is instructed to load /config/serve.json, but its only mounted path is /var/lib/tailscale; a repo-wide search also finds no serve.json or generation step. Thus the sidecar cannot load the configuration intended to publish the panel and gateway, so the documented profile either fails startup or joins the tailnet without forwarding those services. Add the serve configuration and mount it at the declared path.
Useful? React with 👍 / 👎.
| def handle_credentials(self, campos: Dict[str, List[str]]) -> None: | ||
| """Troca usuário e senha do painel, com a política de força inteira.""" | ||
| lang = self.resolve_language() | ||
| user = (campos.get("user", [""])[0] or "").strip() | ||
| password = (campos.get("password", [""])[0] or "").strip() |
There was a problem hiding this comment.
Require the current password before replacing credentials
For browser requests, authentication now succeeds from the eight-hour session cookie, but this handler accepts only the replacement username and password and never re-verifies the current password. A stolen session can therefore replace the dashboard credentials and retain persistent access, even though the SSO settings path explicitly requires the local password to prevent the same escalation. Require the current dashboard or recovery password before calling update_auth_credentials() in both credential-update paths.
Useful? React with 👍 / 👎.
| command: >- | ||
| tunnel --no-autoupdate | ||
| ${TUNNEL_TOKEN:+run --token ${TUNNEL_TOKEN}} | ||
| ${TUNNEL_TOKEN:---url http://9rtk-router:20128} |
There was a problem hiding this comment.
Enforce gateway authentication before starting the public tunnel
Enabling the new tunel profile with an empty TUNNEL_TOKEN publishes 9rtk-router:20128 through a public quick tunnel, while the same compose file still hardcodes both REQUIRE_LOGIN=false and REQUIRE_API_KEY=false. The documentation tells operators to change those values in .env, but these literal compose entries do not interpolate the environment, so that instruction cannot secure the deployed gateway and anyone with the tunnel URL can access its inference and administrative endpoints. Make the flags environment-driven and refuse to start this profile unless both protections are enabled.
Useful? React with 👍 / 👎.
| 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}") |
There was a problem hiding this comment.
Handle histories with no eligible sessions
On a machine without Claude history, or when --since/--until excludes every session or all sessions fall below the minimum thresholds, billable_input and the other metric lists remain empty and the first statistics.median() call raises StatisticsError. This makes the new measurement tool unusable for a common first-run or empty-window case; detect zero analyzed sessions and return a clear no-data result before calculating medians and ratios.
Useful? React with 👍 / 👎.
| config = self.configuracao_sso() | ||
| if not config.esta_ligado() or config.provedor != "saml": | ||
| self.recusa_sso() |
There was a problem hiding this comment.
Reject inactive SAML routes without dereferencing a dict
Whenever OIDC is enabled, rota_existe() admits every /sso/ path, so an unauthenticated request to /sso/saml/iniciar or /sso/saml/acs reaches these handlers. In this panel configuracao_sso() returns the dictionary produced by configuracao_efetiva(), but both handlers call config.esta_ligado() and access config.provedor, causing an AttributeError and a dropped request instead of the intended generic refusal or 404. Check the dictionary provider safely or keep the unimplemented SAML routes out of dispatch.
Useful? React with 👍 / 👎.
| base_dir = self.sso_base_dir() | ||
| novo_segredo = campo("client_secret") | ||
| if novo_segredo and not sso.segredo_vem_do_ambiente(): | ||
| if not sso.grava_segredo(base_dir, novo_segredo): | ||
| self.redirect_to_dashboard("danger", translate("sso.secret_failed", lang)) | ||
| return |
There was a problem hiding this comment.
Validate SSO settings before overwriting the active secret
When an operator supplies a replacement client secret together with an invalid issuer, base URL, or empty allowlist, this code writes the secret file before problemas_da_configuracao() rejects the request. The previous non-secret configuration remains active but now uses the rejected replacement secret, so a save reported as failed can immediately break an otherwise working OIDC login; the same partial update occurs if saving the SQLite configuration subsequently fails. Validate against the prospective secret first and commit the secret only after the rest of the configuration can be saved.
Useful? React with 👍 / 👎.
O que muda
test_irmaos_identicos.py).git.ignoredRepositoriespara evitar ruído de clones emtmp/no Source Control.Validação