foda-se
  • JavaScript 73.9%
  • TypeScript 24.1%
  • PowerShell 0.9%
  • CSS 0.9%
  • HTML 0.2%
Find a file
thomasnrs 695f8d78f6 Colar as contas na tela, porque ninguem tem o .txt na mao
O botao de importar pedia um arquivo. Mas as contas nunca chegam como
arquivo: chegam num bloco de notas, num chat, numa planilha. Salvar como
.txt so para poder importar e uma volta inteira por nada.

A caixa aceita o mesmo formato do .txt (nick:senha por linha), mais ponto
e virgula, virgula e tabulacao, e ignora linha vazia e comentada. O texto
vai cru para o servidor de proposito: quem separa as colunas e o mesmo
parser que ja atende o arquivo. Se a regra morasse tambem no navegador,
os dois caminhos divergiriam no primeiro formato esquisito que
aparecesse.

O relatorio diz nome por nome quem nao foi encontrada no jogo -- "3 nao
existem" sem dizer QUAIS obriga a pessoa a cacar numa lista de trezentas.
E deixa explicito que elas nao foram apagadas: ficam paradas, e a
varredura confere de novo antes de condenar.

A caixa so e esvaziada quando alguma coisa entrou. Se tudo era duplicata
ou linha ilegivel, apagar o que a pessoa colou seria perder a evidencia
bem na hora de olhar o relatorio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:58:30 -03:00
.vscode A prova do encanamento passou na vps-principal, sem o Habblet 2026-08-18 02:58:04 -03:00
docs Conta importada entra parada, e so o jogo decide se ela entra no sorteio 2026-08-22 17:47:36 -03:00
frontend Colar as contas na tela, porque ninguem tem o .txt na mao 2026-08-22 19:58:30 -03:00
motor2 Tres logins por maquina, e o motor passa a dizer por que caiu 2026-08-22 17:13:19 -03:00
scripts/windows Tres logins por maquina, e o motor passa a dizer por que caiu 2026-08-22 17:13:19 -03:00
src-backend oi 2026-08-22 19:51:15 -03:00
.gitignore A telemetria do helper para de virar commit 2026-08-20 21:59:56 -03:00
.lumi-memory.md creator 2026-08-22 18:15:53 -03:00
package-lock.json olha o macaco! 2026-08-16 00:23:27 -03:00
package.json creator 2026-08-22 18:15:53 -03:00
README.md Arquivo grande sobe em pedacos: quem cortava em 100 MB era a borda, nao nos 2026-08-19 01:19:03 -03:00
results (1).txt oi 2026-08-22 19:51:15 -03:00
turnistilefodase.js fodase denv 2026-08-15 20:51:42 -03:00

Painel Bots — Habblet

Painel para alugar bots que entram em salas do Habblet. O usuário compra créditos, pede N bots para uma sala por um tempo que ele mesmo escolhe (5 min a 1h), e workers espalhados por VPSes sobem navegadores Camoufox que logam as contas e permanecem na sala pela janela contratada.

  • Backend + painel: Node 20+, Express 5, SQLite (better-sqlite3), React (Vite)
  • Worker: Node 20+, Camoufox (Firefox com fingerprint aleatório) via Playwright
  • Borda: Cloudflare Tunnel → nginx → backend
  • Produção: https://hyperblet.com

Índice


Arquitetura

A regra que organiza tudo: o servidor é a única autoridade de estado; o worker é um executor sem memória.

O worker não sabe quem está na fila, não decide preço, não escolhe proxy e não expira sessão. Ele pede trabalho, roda o navegador e reporta. Toda decisão de estado é do servidor — que é quem sobrevive ao worker morrer.

flowchart LR
    U[Usuário] -->|https| CF[Cloudflare<br/>hyperblet.com]
    CF -->|túnel| CD
    subgraph VPS Principal
        CD[cloudflared] --> N[nginx :80]
        N -->|estático| D[frontend/dist]
        N -->|/api| B[Backend Express<br/>127.0.0.1:3169]
        S[Supervisor<br/>ciclo de 10s]
        DB[(SQLite)]
        B --- S
        B --> DB
        S --> DB
    end
    W1[Worker A] -->|X-API-Key<br/>https| CF
    W2[Worker N] -->|X-API-Key<br/>https| CF
    W1 --> C1[1 Camoufox por conta]
    W2 --> C2[1 Camoufox por conta]

O tráfego externo só entra pelo túnel: o cloudflared abre uma conexão de saída para a Cloudflare, então não há porta de entrada exposta para o painel. O TLS termina na Cloudflare; dentro da VPS o tráfego é HTTP puro em loopback.

Por que assim

A versão anterior tinha o worker escrevendo direto no SQLite e guardando sessões em Map na memória. Isso quebrava de três formas:

  1. Worker morre → linhas ficavam active para sempre, bots presos em busy, contadores de proxy nunca zeravam. Não havia reconciliação nenhuma.
  2. O monitor de sessão vivia dentro do worker — se o worker caía, o monitor caía junto, então ninguém limpava a sujeira.
  3. SQLite não é acessível remotamente, então múltiplos workers em máquinas diferentes eram impossíveis.

Hoje o único canal entre worker e backend é HTTP autenticado por API key.

Mapa dos arquivos

Caminho Responsabilidade
src-backend/server.js Express, serve a API e o painel buildado, sobe o supervisor
src-backend/config/infra.js Todos os parâmetros de infra, sobrescrevíveis por env
src-backend/services/dispatcher.js Fila, leases, round-robin, proxies, créditos, ciclo de vida de sessão
src-backend/services/supervisor.js O watcher: reconcilia estado, expira sessões, recolhe worker morto
src-backend/services/bootstrap.js Gera o script de instalação de VPS e empacota o worker
src-backend/services/time.js Normaliza timestamps do SQLite (ver armadilhas)
src-backend/controllers/worker.js Worker: automação de navegador (Camoufox, Turnstile, login)
src-backend/worker/runtime.js Worker: orquestração (fila, heartbeat, sessões, reconexão)
src-backend/worker/apiClient.js Worker: cliente HTTP com fila serial e retry
src-backend/controllers/workerController.js Endpoints que o worker consome
src-backend/controllers/provisionController.js Instalação em VPS (token e SSH)
frontend/src/pages/Workers.jsx Painel de workers, fila, eventos e instalação

Configuração

A configuração mora no painel, não no .env. Antes, mudar o preço de um bot exigia SSH na VPS, editar um arquivo e reiniciar o serviço.

A ordem de precedência é painel → .env → padrão do código:

Origem Quando vale Para quê
Painel (settings) sempre que existir o operador muda pela tela; vale na hora
.env se o painel não definiu primeiro boot, máquina de teste
Padrão no código último recurso garante que nunca sobe sem valor

A tela em /admin/settings se desenha sozinha a partir do catálogo em config/infra.js — rótulo, ajuda, unidade e limites moram junto do valor. Uma configuração nova aparece na tela só de ser adicionada ao catálogo, e nunca existe um campo cujo significado só o código conhece.

// config/infra.js — o catálogo
{
    key: 'CREDIT_MINUTES', grupo: 'Negócio', tipo: 'inteiro', padrao: 10, min: 1,
    unidade: 'minutos',
    rotulo: 'Minutos por crédito',
    ajuda: 'Quanto tempo de UM bot na sala vale um crédito. É a base do preço e do estorno.',
}

Não desestruture o módulo. const { CREDIT_MINUTES } = require('../config/infra') congela o valor no require — a edição pelo painel não teria efeito nenhum até reiniciar o processo. Use const cfg = require('../config/infra') e acesse cfg.CREDIT_MINUTES, que é um getter e lê o valor atual a cada acesso.

A validação acontece antes de gravar: um valor fora dos limites nem chega ao banco. E se um valor salvo estiver corrompido (edição direta no SQLite, por exemplo), o reload() avisa no log e cai para o .env/padrão em vez de derrubar o cálculo de preço no próximo acesso.

O .env continua sendo o lugar de segredo e endereçoJWT_SECRET, DB_PATH, PORT, WORKER_API_KEY, BACKEND_URL, as chaves CAMOUFOX_* do worker. Isso não entra no painel de propósito: segredo não se edita por HTTP.


Regras de negócio

Créditos e pedidos

Regra Valor Onde
Preço 1 crédito = 10 min de um bot CREDIT_MINUTES, services/pricing.js
Crédito em dinheiro R$ 0,50 CREDIT_PRICE_CENTS
Janela por pedido 5 min a 1h, de 5 em 5 SESSION_MIN_MS, SESSION_MAX_MS, SESSION_STEP_MS
Janela sugerida 30 min SESSION_DURATION_MS
Bots por pedido 1 a 10 botController.requestBots
Sala só ROOMID numérico botController.normalizeRoomId
Usuário banido não pede bots botController

O preço é linear e fracionário: bots x (duracao / 10min). Cinco minutos de um bot custam meio crédito, e não um crédito arredondado pra cima. Toda conta de dinheiro do sistema sai de services/pricing.js — nenhuma tela, nenhum controller e nenhum teste recalcula preço por conta própria.

A duração pedida pelo cliente é normalizada no servidor (pricing.normalizarDuracao): fora da faixa, o pedido é recusado; fora do passo, é arredondada. O seletor da tela é conforto, não autoridade.

Cada pedido guarda o que ele comprou (duration_ms, reserved_credits).

Dividir um pedido divide o dinheiro junto. O claim encolhe bot_quantity da linha ao entregar um pedaço a um worker. Enquanto reserved_credits ficava inteiro nela, custoPorBot passava a ler "12 créditos por 2 bots" num pedido de 8 — e o irmão nascia sem reserved_credits, caindo no COST_PER_BOT antigo de 10 por bot. Um pedido de 12 créditos devolveu 72. Hoje a parte que fica leva o proporcional e o irmão leva exatamente o resto (por subtração, para a soma continuar igual ao que foi pago), e ambos herdam duration_ms. Mudar a tabela de preços no painel muda os pedidos daqui pra frente e não reescreve quanto vale o estorno de um pedido de ontem.

Reserva e consumo

O saldo sai da conta na hora do pedido, como reserva. O que o cliente de fato gasta é o tempo entregue, medido pelo relógio (pricing.fracaoConsumida), e o que sobrar volta quando a sessão fecha.

Não é um detalhe de implementação: debitar aos poucos durante a sessão deixaria o mesmo saldo ser gasto duas vezes — o cliente abriria um segundo pedido com crédito que já está comprometido com o primeiro. Reservar na entrada e acertar na saída dá o mesmo número final e protege o saldo no meio do caminho.

O painel mostra os dois lados correndo (creditos_reservados x creditos_consumidos, com termina_em para a barra andar entre um refresh e outro), então o cliente vê o consumo subir em vez de ver o saldo sumir.

bot_sessions.consumed_credits é gravado no fechamento, quando o relógio para: depois disso o número não muda mais e qualquer tela pode somar sem recalcular.

Ao fazer o pedido, dentro de uma transação: os créditos são debitados, N contas livres são reservadas (status='busy', current_room_id) e o pedido nasce pending. Se faltar conta ou crédito, nada acontece — a transação inteira é revertida.

Os três pools de contas

Conta de bot existe em três lugares, e a diferença entre eles é de dono, não de arquivo:

Pool Onde mora De quem é Entra em sorteio?
Pública (casa) bot_accounts com owner_user_id IS NULL de ninguém, atende todo mundo sim, com teto por cliente
Pessoal bot_accounts com owner_user_id = cliente do cliente sim, só nos pedidos dele
À venda contas_a_venda mercadoria nossa não — não sobe bot, não aparece na disponibilidade de ninguém

A conta à venda só vira linha em bot_accounts no instante da compra. Enquanto está no estoque ela não tem status operacional, não é sorteada e não conta na disponibilidade — é inventário, não robô.

Teto de bots da casa por cliente

MAX_HOUSE_BOTS_PER_USER (5) limita quantas contas nossas um cliente pode ter na rua ao mesmo tempo. As contas do pool pessoal dele não entram nessa conta e não têm teto — são dele. Admin não tem teto: é quem testa e quem apaga incêndio.

A conta é feita sobre bot_requests.casa_reservados somado nos pedidos que ainda estão de pé, e não sobre as sessões vivas: bot reservado e ainda subindo também ocupa a cota, senão bastaria disparar três pedidos seguidos para furar o teto na janela em que eles ainda estão logando. Quando um pedido é dividido entre workers, esse número é dividido junto com os bots e o crédito.

Estourou o teto, o pedido é recusado com o motivo certo ("você já está usando 5 de 5 bots da casa"), e não com o genérico "nenhum robô disponível" — a diferença muda o que o cliente pode fazer a respeito. E se ele tiver pool próprio, o pedido é servido com o que couber da casa e o resto vem do pool dele.

Venda de contas

O teto de bots da casa não é uma parede: é a porta da loja. Quem precisa de mais bots ao mesmo tempo compra a conta por BOT_SALE_PRICE (2 créditos), ela entra no pool pessoal dele e passa a ser dele — e pool pessoal não tem teto de uso, só o de tamanho (MAX_PERSONAL_BOTS, 50).

A compra é POST /bots/loja/comprar, tudo dentro de uma transação: ou o cliente fica com as contas e sem os créditos, ou nada aconteceu. Metade disso — crédito debitado e conta não entregue — seria pior que a falha inteira.

De onde sai a conta vendida, nessa ordem:

  1. Estoque (contas_a_venda, linhas com sold_at IS NULL). Abastecido em Central de bots Contas à venda, com o mesmo .txt de sempre (nick:senha por linha). Nick que já existe em bot_accounts é recusado na importação: o campo é UNIQUE lá, e a venda quebraria na frente do cliente.
  2. Pool público, e só até PUBLIC_POOL_SALE_PCT dele (padrão 0, ou seja, desligado). O pool comum é o que atende todo mundo; vendê-lo encolhe o serviço para os outros clientes, então isso é uma decisão consciente e não um acidente de estoque vazio.

Do pool público só sai conta que já entrou em sala — vender uma que nunca logou seria vender um problema conhecido por 2 créditos. Entre as provadas, saem primeiro as menos rodadas: a conta que mais entregou fica com a casa.

A base da porcentagem é fixa: pool público de hoje mais o que já saiu dele por venda. Sem somar o que já saiu, cada venda encolheria a própria base e o teto andaria junto — 20% de um pool que diminui a cada venda nunca chegaria a lugar nenhum. Com a base fixa, "20%" quer dizer o que parece: de cada 10 contas que a casa tinha, no máximo 2 viram conta de cliente.

O livro de vendas é a mesma tabela. A linha não some quando vende, ganha sold_at, sold_to_user_id e sold_price_credits; venda tirada do pool público também entra ali, com origem='publico'. Assim existe um lugar que responde "o que já foi vendido, para quem, por quanto". A senha é apagada da linha na venda: depois disso quem guarda a credencial é bot_accounts, e manter uma cópia no livro só dobraria o estrago de um vazamento.

Trocar de prateleira nos dois sentidos. Depois de importar um bloco novo, o caminho normal é parte atender a casa e parte ir para venda — então POST /admin/bots/:id/vender tira do pool e põe à venda, e POST /admin/vendas/:id/usar faz o contrário. A saída do pool usa a mesma regra de sempre: conta que nunca rodou some, conta com histórico é arquivada (as sessões dela continuam sendo o registro do que o cliente recebeu) e conta que está numa sala agora não sai. A senha é copiada antes do arquivamento, que a apaga.

Parâmetro Padrão Para quê
BOT_SALE_PRICE 2 créditos preço de uma conta
PUBLIC_POOL_SALE_PCT 0% quanto do pool público pode ser vendido
MAX_PERSONAL_BOTS (env) 50 tamanho máximo do pool pessoal

Tirar uma conta do pool sem apagar o histórico

São duas coisas diferentes, e antes estavam presas uma na outra. O histórico de sessões é o registro do que o cliente recebeu — não se apaga. A conta, sim, precisa poder sair: lote velho substituído, nick queimado, limpeza para importar outro bloco. Como o nome de quem entrou na sala só existia na linha da conta, remover a conta apagaria o registro junto, e a saída era "marque como suspenso" — que deixa lixo no pool para sempre.

Hoje bot_sessions.bot_username guarda o nome na própria sessão, gravado no openSession. Com o histórico se sustentando sozinho, a remoção separa dois casos:

Conta O que acontece
Nunca rodou Sai do banco de vez — não há o que preservar
Tem histórico Vira arquivo: some do pool e do painel, o nick fica livre para reimportar, e a senha é apagada (credencial de conta que não usamos mais é só risco parado)
Está numa sala agora Não sai. Seria puxar o bot do cliente do chão

DELETE /admin/bots faz a limpeza em lote (todas, reprovadas, sem_uso, castigo, suspensas) e só toca no pool da casa — as contas do pool pessoal são do cliente, e uma limpeza que as levasse junto seria destruir propriedade alheia num botão chamado "limpar tudo". Conta em sala é pulada, não é erro: quem pediu a limpeza quer o pool trocado, e parar tudo por causa de um bot rodando seria pior do que limpar o resto e dizer o que ficou.

Estorno

O estorno é proporcional aos bots que não entraram e acumulativo, limitado ao que o usuário pagou.

Situação Resultado
3 de 3 entraram completed, sem estorno
2 de 3 entraram completed, estorna o preço de 1 bot pela janela comprada
0 de 3 entraram failed, estorna o pedido inteiro
Pedido falhou 3 entregas seguidas failed, estorna o que ainda não foi estornado

Antes existia um bug aqui: no sucesso parcial o código marcava completed e só então chamava o estorno, que exigia status processing. O estorno parcial nunca acontecia. Hoje refundBots() acumula em refunded_credits e não depende do status.

Tempo de sala que não foi entregue

O cliente não compra "um bot": compra um bot na sala por um tempo. Se a sessão cai antes do prazo por culpa da infra, o pedido já está completed e, sem uma regra explícita, esse tempo evapora — foi exatamente o que aconteceu: 76h pagas, 10,4h entregues, 19 de 19 sessões cortadas antes da hora, zero estorno.

Toda sessão encerrada por culpa nossa (isInfraFault(): servidor_reiniciado, worker_sem_heartbeat, lease_expirado, max_reconnect_attempts, qualquer sinal_*…) com mais de 1 minuto de prazo restante é marcada interrupted e fica esperando o acerto do supervisor:

Situação O que acontece
Sobrou ≥ REPLACEMENT_MIN_REMAINING_MS (10 min) e ainda não repomos 3x Abre um pedido de reposição, sem cobrar nada
Sobrou pouco tempo, ou já repomos MAX_REPLACEMENTS_PER_REQUEST (3) vezes Estorna crédito proporcional ao tempo não entregue
Reposição envelheceu na fila sem entrar na sala Cancelada, e vira estorno
Encerramento legítimo (prazo cumprido, admin, pedido cancelado, cliente banido) Nada — é o fim normal

A reposição é um bot_requests de verdade, com is_replacement=1 e parent_request_id apontando para a raiz da cadeia (reposição de reposição continua contando no pedido original, senão o teto de 3 nunca seria atingido). Ela carrega resume_expires_at, o prazo herdado: openSession usa esse valor no lugar de isoIn(pricing.duracaoDoPedido(request)), então o bot volta pelo tempo que faltava, não ganha uma janela nova. Ela também herda o duration_ms do pai: essa janela é o denominador de contractFraction(), e sem herdá-la a fatia de uma reposição de um contrato de 1h seria medida contra os 30 min do padrão — um estorno de 1/4 sairia como o bot inteiro.

Duas armadilhas de dinheiro, ambas resolvidas dentro de refundBots():

  • Uma reposição não pagou nada. Estornar contra ela criaria crédito do nada, já que o teto (bot_quantity * pricing.custoPorBot()) seria calculado sobre um pedido gratuito. refundBots() redireciona para a raiz da cadeia.
  • Uma reposição vale menos que um bot. Se ela existia para devolver 20 min e falha, o prejuízo é 20 min, não a janela inteira. contractFraction() escala o estorno pela fatia do contrato que aquela reposição cobria — tanto quando a entrega falha (finishRequest) quanto quando a reposição vence na fila (expireStaleReplacements).

E uma terceira, do lado do painel: encerrar uma sessão à mão (killSession) devolve o tempo que faltava, porque o cliente comprou uma janela e nós a cortamos. estornar: false no corpo é a saída para o caso em que o encerramento é a punição (sala irregular, abuso) — aí devolver seria premiar.

O acerto roda no tick() do supervisor e também no boot — o que morreu junto com a API na queda anterior é resolvido no arranque, sem esperar o cliente reclamar. No painel a reposição aparece pendurada no pedido original (getMyHistory aninha), porque solta no histórico ela pareceria uma compra que o cliente nunca fez.

Divisão de um pedido entre workers

Os logins de um worker são serializados pelo portão: um pedido de 6 bots num worker só demora 6 intervalos — 12 minutos no padrão. As contas podem logar em paralelo em máquinas diferentes, e é isso que a divisão explora: os mesmos 6 bots em 3 workers sobem em 4 minutos.

A cota é ceil(bots / destinos), onde destino é um worker com vaga livre que ainda não tem um pedaço deste pedido. Sem essa segunda condição a divisão vira auto-similar e se fragmenta demais — 6 bots virariam 2+2+1+1 para três workers, em vez de 2+2+2. O resto vira um pedido irmão que volta para a fila e pode ser dividido de novo pelo próximo worker.

Cada pedaço é um bot_requests de verdade, com lease, tentativas e estorno próprios: reusa toda a máquina que já existe. Para o cliente continua sendo um pedido só — getMyHistory agrupa os pedaços por split_of_request_id e soma quantidade, estorno e reposições. Ele pediu 6 bots, não "2 + 2 + 2".

A armadilha da divisão é dois pedaços do mesmo pedido (mesma sala, mesmas contas reservadas) escolherem as MESMAS contas — a mesma conta do Habblet subindo em dois navegadores ao mesmo tempo, que é exatamente o que o jogo detecta. bot_accounts.current_request_id marca de quem é cada conta e fecha esse buraco.

Desligar em Configurações → Fila e workers (SPLIT_ACROSS_WORKERS).

Rotação de conta e proxy

Um bot que não consegue entrar na sala não perde a vaga. Antes perdia: o worker anotava a falha e ia para o próximo, então com metade do pool de proxies queimado o cliente recebia metade do pedido e o resto virava estorno — mesmo havendo proxy bom e conta boa sobrando.

A vaga é uma posição no pedido, não um par fixo conta+proxy. Quando o par falha, o worker reporta em POST /worker/requests/:id/rotate e o servidor devolve outro par para tentar de novo, até MAX_SLOT_ATTEMPTS (3) pares diferentes. Quem escolhe continua sendo o servidor — o worker não decide conta nem proxy, igual em todo o resto da arquitetura.

Quem trocar depende do erro. classifyLoginFailure() lê a mensagem que estourou no worker e atribui a culpa:

Erro Culpa O que troca
Habblet em manutenção ninguém nada — a vaga acaba ali mesmo
Verificação Cloudflare não foi concluída proxy só o proxy — é reputação de IP, a conta nem foi digitada
net::ERR_*, timeout, ECONNRESET, túnel proxy só o proxy
Redirecionou de volta ao login, senha/banido conta só a conta — o IP está bom, a credencial que não
Login não foi confirmado, Botão de submit não encontrado ambos os dois, por falta de pista melhor

Botão de submit não encontrado após resolução do Turnstile tem a palavra "Turnstile" mas significa que o Turnstile passou — por isso a regra dele vem antes da regra de Cloudflare na lista de pistas.

A vaga nunca repete um proxy que já falhou nela (proxy_leases do par pedido+conta vira a lista de exclusão), senão o mesmo proxy queimado voltaria sempre: ele continua sendo o menos usado justamente porque ninguém consegue usá-lo.

Jogo em manutenção não é culpa de ninguém

O worker lê o título e o corpo da página assim que ela carrega. Se bater com CAMOUFOX_MAINTENANCE_TEXT (manutenção|maintenance|em breve voltamos), ele falha imediatamente com Habblet em manutenção: "...".

Sem esse reconhecimento, a manutenção chegava aqui como "o campo de login não apareceu" — que a leitura de erro classifica como problema de rede e cobra do proxy. Uma janela de manutenção do Habblet mandaria o pool de IPs inteiro para a quarentena, e na volta do jogo quem estaria fora do ar seríamos nós. Aconteceu em produção (título Habblet - Manutenção, página sem formulário nenhum, três tentativas queimadas por vaga).

Reconhecida a manutenção: ninguém leva marca, o proxy volta ao pool na hora, a conta volta ao pool e a vaga não insiste — não existe par conta+proxy que resolva um site desligado, e cada tentativa custa 45s mais o portão de login. O pedido falha e o cliente recebe tudo de volta.

E a tela avisa antes de o cliente pagar: app_state.jogo_em_manutencao vai junto na disponibilidade e vira uma faixa vermelha no painel. O aviso some sozinho em 10 minutos, ou no instante em que qualquer bot entra numa sala — que é a prova de que o jogo voltou.

A fila do pool: quem tentou há mais tempo vai primeiro

A ordem é last_attempt_at NULLS FIRSTa última vez que a conta foi apresentada ao jogo, tenha dado certo ou não.

Era last_used_at, que só era carimbado no sucesso, e isso travou o pool da casa em produção: a conta que falhava continuava sem data nenhuma, ou seja, voltava para a frente da fila e era a primeira a ser tentada de novo — para sempre. Com 29 contas que nunca logaram na cabeça da fila, as 27 que funcionavam nunca mais foram sorteadas, e o sintoma na tela era "os bots da casa não sobem mais, só os do meu pool".

Carimbar a tentativa é o que faz o rodízio existir: cada conta vai para o fim da fila assim que é usada, e o pool inteiro passa antes de alguém repetir.

Descanso: espaçamento, não castigo

Uma conta que acabou de falhar um login fica LOGIN_COOLDOWN_MS (5 min) com resting_until no futuro. Não conta falha, não muda status, não é acusação de nada: o jogo conta tentativas por conta, e a rotação devolve a conta ao pool na hora — sem descanso, o pedido seguinte a reapresenta segundos depois, e é assim que um tropeço vira bloqueio.

É preferência, não filtro: conta descansando vai para o fim da fila, mas é usada se não houver outra. Num aperto em que todas falharam, filtrar deixaria o pedido sem conta nenhuma.

Surto: o mesmo erro em todo mundo não é culpa de ninguém

Quando o jogo aperta o anti-bot, toda conta falha igual. Sem tratamento, o palpite de ambos marcava cada uma delas e três falhas seguidas mandavam a conta de castigo — e como o pedido gasta o pool da casa primeiro, era sempre ele que apanhava.

Um surto é reconhecido quando as três condições valem:

  1. a mensagem não casa com nenhuma pista (a culpa era palpite);
  2. a mesma assinatura de erro atingiu ao menos 3 contas diferentes em 10 min;
  3. ninguém entrou em sala nessa mesma janela.

A terceira é o desempate, e ela é o que separa "o jogo está barrando todo mundo" de "estas contas não prestam". Sem ela o surto virava álibi universal: em produção, contas que nunca logaram falhavam juntas, uma absolvia a outra, e o pool ficava preso nelas — enquanto contas boas entravam na sala no mesmo minuto.

Reconhecido o surto, ninguém leva marca e as marcas recentes com aquela mesma assinatura são desfeitas (absolverPeloSurto). O que não é desfeito é o registro: total_failures e last_attempt_at ficam de pé. A absolvição tira o castigo, não a memória.

Conta reprovada: nunca entrou, sai do pool

Castigo é temporário porque proxy e conta se recuperam sozinhos. Mas conta apresentada ACCOUNT_PROOF_LIMIT (3) vezes, com proxies diferentes, que nunca entrou em uma sala sequer não é conta com azar: é conta que não presta (senha errada, lote não verificado, banida na criação).

Ela vai para status='unverified' — fora do sorteio, esperando decisão humana. No painel ela aparece com a ficha na coluna "Entrou?" (nunca · N tentativa(s)), e há duas saídas: reativar (o que zera a ficha, senão ela reprovaria de novo na primeira falha) ou limpar o lote inteiro em DELETE /admin/bots com o escopo reprovadas.

Só conta a tentativa em que a conta era suspeita: falha atribuída ao proxy por evidência (Cloudflare, timeout, 403/429/5xx) não pesa contra ela, e falha durante um surto não pesa contra ninguém. Sem esse cuidado, um pool de proxies ruim reprovaria um lote novo inteiro na chegada.

Castigo por falhas seguidas. Uma falha isolada não diz de quem é a culpa; a repetição diz. Cada falha incrementa consecutive_failures; ao bater o limite o recurso vai para status='quarantined' com quarantined_until, sai do sorteio, e o supervisor o devolve sozinho quando a pena vence.

Parâmetro Padrão Para quê
MAX_SLOT_ATTEMPTS 3 pares diferentes por vaga
PROXY_FAILURE_LIMIT 3 falhas seguidas → proxy de castigo
ACCOUNT_FAILURE_LIMIT 3 falhas seguidas → conta de castigo
QUARANTINE_MS 30 min duração do castigo
LOGIN_COOLDOWN_MS 5 min descanso da conta depois de um login falhado (0 desliga)
ACCOUNT_PROOF_LIMIT 3 tentativas sem nenhuma entrada em sala → conta reprovada (0 desliga)

Seguidas é a palavra importante: openSession() — a prova de que o bot entrou na sala — zera o contador dos dois lados do par. Um sucesso vale mais que as falhas anteriores, que provavelmente eram do outro lado.

O castigo é temporário de propósito. Proxy e conta se recuperam sozinhos (IP rotativo, bloqueio temporário do jogo), então banir de vez só encolheria o pool a cada blip de rede. Para banir mesmo existe status='dead' no proxy, que o supervisor não desfaz.

Cada tentativa passa pelo portão de login, então 3 tentativas custam até 6 minutos de parede a mais por vaga. O lease do pedido continua sendo renovado pelo heartbeat durante isso (o worker manda requestIds dos jobs em andamento), então o supervisor não o considera órfão.

Ciclo de vida do pedido

pending ──claim──> processing ──resultado──> completed | failed
   ^                    │
   └────requeue─────────┘   (lease vencido, worker sumiu, servidor reiniciou)

attempts conta entregas frustradas, não entregas feitas — um pedido reenfileirado por motivo externo não queima tentativa à toa. Ao chegar em MAX_REQUEST_ATTEMPTS (3), falha em definitivo e devolve os créditos restantes.

As contas continuam reservadas quando um pedido volta para a fila. Liberá-las faria o pedido nunca mais achar bot para executar: ele voltava para a fila e no claim seguinte batia em "contas insuficientes", em loop. Só na falha definitiva é que voltam para o pool.

Estados de sessão

Status Significa
active bot na sala, worker reportando heartbeat
reconnecting página caiu, worker tentando relogar
expired cumpriu a janela contratada
crashed esgotou as tentativas de reconexão, ou lease venceu
freed encerrada de propósito (admin, shutdown do worker, fim do pedido)

As colunas interrupted, settled_at e replacement_request_id guardam o acerto: interrupted=1 marca "cortada por culpa nossa", settled_at marca "já resolvida" (reposta ou estornada). Sem o settled_at, a mesma sessão seria reavaliada a cada ciclo do supervisor, para sempre.

O que o cliente vê dos nossos bots

A tela de pedidos mostra os bots que estão na sala dele com boneco e metade do nick (Vrqf••••). Os dois lados dessa escolha importam:

  • Boneco porque "5 bots" não diz qual caiu. Com cara e tempo restante, o cliente acompanha a entrega em vez de adivinhar.
  • Meio nome porque conta da casa é nosso estoque. Nick completo na mão de quem quiser denunciar é o caminho mais curto para perder a conta.

O detalhe que faz a regra valer: a API de imagem do Habblet desenha por figura (?figure=hd-180-1.ch-255-66…), não por nome. services/figuras.js busca a figura uma vez, guarda em bot_accounts.avatar_figure e é ela que vai para a tela — o nick inteiro não sai do servidor. Se a tela montasse a URL pelo nome, o nome estaria na URL e mascarar o texto seria teatro.

A busca acontece fora do caminho da resposta: quem pergunta leva o que já está guardado (ou nada, e a tela mostra um vulto), e a figura chega no refresh seguinte. Buscar na hora faria a lista de pedidos — consultada a cada poucos segundos — esperar por uma API de terceiro.

No painel do admin o nick aparece inteiro, com o boneco ao lado: ali o estoque é seu.

Login do usuário no painel

Dois caminhos, ambos em authController:

  1. Primeiro acesso / reset: POST /login/request gera um código HBT-###### que o usuário coloca no lema do Habblet. POST /login/confirm consulta api.habblet.city e valida. Confirmado, define um PIN de 6 dígitos.
  2. Depois: POST /login/pin com nick e PIN.
  • PIN guardado com scrypt + salt, comparado com timingSafeEqual.
  • 5 tentativas erradas bloqueiam por 15 minutos, por par (usuário, IP).
  • Sessão JWT dura 30 dias. Papéis: member e admin.

Modelo de dados

SQLite em WAL, migrações idempotentes no boot (database/db.js).

Tabela Para quê
users clientes do painel, créditos, papel, PIN
bot_accounts contas do Habblet usadas como bot
bot_requests pedidos, com lease de execução
bot_sessions uma linha por bot dentro de uma sala
proxy_blocks pool de proxies
proxy_leases uma linha por conta usando um proxy (fonte de verdade)
workers workers registrados, hash da API key, capacidade, heartbeat
worker_events trilha de auditoria do painel
worker_install_tokens convites de instalação (curtos, descartáveis)
provision_runs histórico das instalações por SSH, com log
transactions pagamentos
login_verifications, login_rate_limits fluxo de login

Campos de coordenação que valem conhecer:

  • bot_requests.lease_expires_at — posse do pedido por um worker. Vencido sem sessão viva, volta para a fila.
  • bot_sessions.lease_expires_at — renovado a cada heartbeat. Vencido, a sessão é considerada órfã e bot + proxy voltam ao pool.
  • workers.last_heartbeat — sem sinal por WORKER_TIMEOUT_MS, o worker perde tudo que segurava.
  • proxy_blocks.in_usecache, derivado de proxy_leases, reconciliado pelo supervisor.
  • bot_requests.parent_request_id / is_replacement / resume_expires_at — a cadeia de reposição: de quem é filha, se é gratuita, e o prazo herdado do pedido original.
  • bot_sessions.interrupted / settled_at / replacement_request_id — o acerto da sessão: cortada por culpa nossa, já resolvida, e para qual reposição foi.
  • bot_accounts e proxy_blocks: consecutive_failures, total_failures, total_successes, last_failure_reason, quarantined_until — saúde do recurso, usada pela rotação. O que manda no sorteio é o contador de falhas seguidas; os totais são só para o painel.

Distribuição de trabalho

Round-robin dinâmico

A distribuição é por pull: o worker pede trabalho. Sem regra nenhuma, quem faz polling mais rápido leva tudo. Duas regras em dispatcher.shouldYield() transformam isso num rodízio ponderado pela carga real:

  1. Quem está proporcionalmente mais vazio tem prioridade.
  2. Empatou na carga? Ganha quem recebeu trabalho há mais tempo.

A cessão vale só enquanto o pedido esperou menos que DISPATCH_FAIRNESS_WINDOW_MS (20s). Passado isso, quem pedir leva — assim um worker que manda heartbeat mas travou o loop de claim não segura a fila inteira.

Claim atômico

claimWork() roda inteiro numa transação do SQLite. Dois workers pedindo ao mesmo tempo nunca levam o mesmo pedido — há teste cobrindo isso. O claim entrega de uma vez: dados do pedido, credenciais das contas e um proxy por conta.

Capacidade

capacity é o número de sessões simultâneas que a máquina aguenta. O servidor conta como ocupado: sessões vivas + bots de pedidos já entregues que ainda não viraram sessão. Sem contar as reservas, o worker pegaria mais trabalho do que aguenta na janela entre o claim e o login.

Como cada conta roda num Camoufox próprio (~300500 MB), ajuste a capacidade pela RAM da VPS. Referência: 11 GB de RAM → capacidade 4 confortável.

Telemetria da VPS

O heartbeat carrega duas coisas além do "estou vivo":

Saúde da máquina — carga em três janelas (1, 5 e 15 min), memória da máquina e do processo separadas, e disco. A carga vem normalizada pelos núcleos: "load 4" não diz nada sozinho, mas "load 4 em 2 núcleos" diz que a fila de espera tem o dobro do que a máquina aguenta, então 100% no painel significa exatamente lotada. As três janelas separam pico de tendência — carga 2 subindo é outra coisa de carga 2 baixando, e a decisão do admin depende de qual das duas é. O disco entra porque cada Camoufox baixa perfil e cache: VPS que enche de disco falha o login sem dizer por quê, com o mesmo sintoma de conta ruim.

O que a máquina está fazendo agorabrowsers abertos, em sala, logando, na fila do portão, reconectando, vagas pendentes. "5 sessões ativas" não diz se a VPS está parada ou fervendo: entre o pedido chegar e o bot entrar na sala há três estados que custam CPU e RAM e não apareciam em lugar nenhum. É com esses números que o portão de login deixa de ser regulado no escuro: fila cheia com CPU baixa quer dizer intervalo folgado demais; CPU no teto com fila vazia, o contrário.

Tudo isso é gravado no worker (e não só devolvido), porque quem pergunta é o painel — a qualquer momento, e não no ritmo do heartbeat.

Reiniciar o processo x reiniciar a máquina

São duas ordens diferentes, e clicar na errada custa minutos:

POST /admin/workers/:id/restart POST /admin/workers/:id/reboot
O que cai só o processo do worker a VPS inteira
Quem levanta Restart=always do systemd, em segundos o provedor, em 30s2min
Resolve worker travado num estado que ele não desfaz disco cheio, memória fragmentada, rede presa no host

As duas são entregues no próximo heartbeat e uma vez só (consomeOrdemDe… lê e apaga na mesma tacada): repetir faria a máquina reiniciar em laço, que é o pior estado possível — ela volta, pega o pedido e cai de novo antes de entregar.

No reboot, o worker encerra limpo antes de mandar reiniciar, então as sessões são recolhidas e os pedidos voltam para a fila em vez de ficarem presos até o lease vencer.

Heartbeat

A cada 10s o worker manda o que realmente tem em memória:

POST /api/worker/heartbeat
{ "sessionIds": [12, 13], "requestIds": [45], "capacity": 4, "metrics": {...} }

O servidor cruza com o banco e responde com ordens:

{ "revokeSessions": [13],   // já encerradas pelo servidor: feche o navegador
  "draining": false,        // pare de aceitar pedidos novos
  "capacity": 4,            // capacidade nova, sem reiniciar
  "freeSlots": 2 }

Sessão que o worker não citou fica com o lease vencido e é recolhida.


O supervisor (o watcher do servidor)

Roda no processo do backend, ciclo de 10s (services/supervisor.js). É a peça que faltava na versão antiga.

Na inicialização (reconcileOnBoot): nenhum worker pode estar vivo ainda, então tudo marcado como ativo é sobra de processo morto. Encerra sessões, devolve pedidos à fila, corrige contadores. É isto que resolve o "fechei o worker e as sessões antigas continuavam contando".

A cada ciclo:

Verificação Ação
Worker sem heartbeat > 35s libera sessões, pedidos, bots e proxies dele
Sessão passou da janela contratada encerra como expired
Lease de sessão vencido encerra como crashed
Pedido processing com lease vencido e sem sessão devolve à fila
proxy_blocks.in_use divergente recalcula a partir de proxy_leases
Concessão de proxy órfã libera
Bot busy sem sessão e sem pedido devolve ao pool

Os contadores se autocorrigem: mesmo que um crash aconteça no pior momento possível, o ciclo seguinte realinha tudo com a realidade das sessões vivas.

Botão ⟳ Reconciliar no painel força um ciclo na hora.

Cada passo do ciclo cai sozinho. Antes todos viviam dentro de um try único: o primeiro passo que estourasse abortava os seguintes, em silêncio. Como a reconciliação de contadores é o penúltimo, bastava um erro em qualquer lugar antes dela — um pacote de worker ilegível, uma linha estranha no acerto — para proxy_blocks.in_use congelar no último número e o painel mostrar proxy ocupado sem nada rodando, indefinidamente. E o log só dizia Erro no ciclo, sem dizer onde. Hoje cada passo é embrulhado por passo(nome, fn): falha com nome no log, e o ciclo segue.

Na tela de proxies, o número da coluna Em uso é clicável e lista quem está segurando cada IP — sessão, pedido, conta, worker e desde quando —, marcando as concessões sem dono vivo. É a diferença entre "o contador está mentindo" e "tem sessão rodando de verdade", que o número sozinho nunca respondeu. POST /admin/proxies/reconciliar (botão Reconciliar uso) força o acerto e devolve o antes/depois mais o que sobrou segurando.


Consumo de banda dos proxies

Cada lote de proxy vem com uma franquia de dados. Sem medir, a única forma de descobrir que ela acabou é o proxy parar de funcionar no meio de uma sessão — e aí o prejuízo já aconteceu.

Como é medido. O worker instrumenta o contexto de cada navegador e soma duas fontes, porque uma sozinha mentiria:

Fonte Por quê
request.sizes() (HTTP) dá o tamanho na rede — corpo já comprimido + cabeçalhos. Somar content-length daria o tamanho descomprimido e erraria pra mais
frames de WebSocket o cliente do jogo vive num socket, e ele não aparece em sizes(). Sem contá-lo o número ficaria absurdamente baixo — justamente o tráfego que mais dura

Fica de fora o overhead de TLS/TCP e o cabeçalho de cada frame: é uma estimativa por baixo do que o provedor cobra, e a tela diz isso.

Telemetria nunca derruba entrega: se o contexto não expuser eventos, o bot entra na sala do mesmo jeito e só não é medido.

Como é contabilizado. O worker reporta o acumulado da sessão a cada heartbeat; o servidor guarda o último e soma só a diferença. É essa escolha que torna o número confiável:

  • heartbeat repetido não conta duas vezes;
  • heartbeat perdido não perde consumo (o próximo traz o acumulado inteiro);
  • worker que reinicia zera o medidor dele — o servidor detecta a regressão e ignora, em vez de somar negativo ou recontar tudo.

Lotes. Um lote é a compra: N proxies com uma franquia. É no lote que a franquia estoura, então é nele que a conta fecha. proxy_batches guarda nome, provedor, franquia, custo e vencimento; proxy_usage_daily guarda o histórico por dia, que é o que permite projetar quando acaba (média dos últimos 7 dias com consumo — a média desde sempre diluiria um lote usado só na última semana e diria "dura um ano" no dia em que ele vai estourar).

Rodízio entre blocos. A alocação intercala os blocos. Deixada por conta da ordem natural, esvaziaria a franquia de um bloco inteiro antes de encostar no outro — sobrando um bloco morto e um bloco vencendo com saldo.

A escassez que se administra aqui é banda, não sessão. Por isso a ordem é:

  1. in_use_real — uma conta por proxy enquanto houver proxy livre (regra de fingerprint, mais importante que qualquer balanceamento);
  2. pressao — fração da franquia já gasta, arredondada em faixas de 1%;
  3. carga — sessões vivas ÷ tamanho do bloco.
  4. last_lease_atquem saiu há mais tempo vai primeiro; nunca usado vem antes de todos.

O quarto critério é o que faz a vez andar. Sem ele os desempates são fixos, e assim que uma concessão é devolvida o mesmo IP ganha de novo: num dia de produção um proxy carregou 18 contas e outro 2, com os 40 ativos e livres. Repetir IP em conta atrás de conta é o padrão que o jogo procura, mesmo que nunca haja duas ao mesmo tempo. A marca é gravada na saída e não na devolução — o que importa é há quanto tempo aquele IP apareceu para o jogo, não há quanto tempo está parado.

Normalizar pela franquia é o que faz um bloco de 5 IPs com 50 GB receber o dobro de tráfego por IP de um bloco de 20 IPs com 100 GB — que é exatamente o que foi comprado. E o arredondamento importa: sem ele, dois blocos quase empatados nunca empatam de fato (0,1000 contra 0,1001), e como a pressão só se move quando os bytes chegam pelo heartbeat, uma rajada de 6 claims no mesmo segundo cairia inteira no mesmo bloco. Com a faixa, quem está perto empata e a carga — que muda na hora, a cada lease — intercala.

Proxy sem bloco entra com pressão neutra (0,5): sem franquia declarada não dá para saber que ele está folgado.

O bloco é escolhido no cadastro, tanto no proxy avulso quanto na importação do .txt — que é o caminho real, já que proxy se compra em bloco e o provedor entrega uma lista por compra. Importar solto e atribuir depois é o passo em que alguém esquece metade e o consumo daquele bloco passa a mentir. Dá para mover de bloco direto na lista de proxies.

No código o bloco é proxy_batches / "lote", porque proxy_blocks já é a tabela de proxies individuais. Na tela é sempre bloco, que é como se fala.

A tela fica em /admin/consumo.


Proxies

Um proxy por conta. No máximo 2 contas por proxy, e só quando o pool aperta.

O proxy do Camoufox é definido no lançamento do navegador. Como antes era um navegador por pedido, todas as contas de um pedido saíam pelo mesmo IP — e, pior, com o mesmo fingerprint, já que o Camoufox sorteia a impressão digital uma vez por navegador. Duas contas assim são triviais de ligar uma à outra.

A alocação (dispatcher.acquireProxyLease) ordena por menos usado primeiro: enquanto houver proxy vazio, cada conta ganha o seu; só depois compartilha, até o teto de PROXY_MAX_ACCOUNTS.

Seleção e reserva acontecem na mesma transação. Separadas, dois workers caíam na janela entre o SELECT e a escrita e recebiam o mesmo proxy.

Quando o proxy volta ao pool

Evento O que é liberado
Conta sai da sala (sessão fecha) o proxy daquela conta
Login da conta falhou na hora, ao reportar o resultado
Worker morre ou desliga todos os proxies das contas dele
Divergência detectada supervisor recalcula

Pool esgotado

Se acabarem os proxies, a conta sobe sem proxy em vez de travar o pedido. Fica registrado em worker_events e a sessão aparece como "sem proxy" em amarelo no painel. O card 🛰️ Proxies mostra em uso / capacidade, ficando vermelho quando encosta no teto.

Capacidade do pool = proxies ativos × PROXY_MAX_ACCOUNTS.


O worker por dentro

Um navegador por conta

Cada conta sobe o próprio Camoufox, com IP e fingerprint próprios. A sessão é dona do navegador: quando a conta sai, o navegador dela fecha — só ele.

Portão de login (intervalo de 120s)

A página do Habblet precisa carregar por completo antes de aceitar o login, e uma aba em segundo plano tem o carregamento estrangulado pelo navegador. Dois logins ao mesmo tempo significam que um deles está em background — e trava.

withLoginGate() garante um login por vez em todo o worker, com LOGIN_DELAY_MS (120s) entre um e o seguinte. Vale para o worker inteiro, não só dentro de um pedido: com capacidade > 1 há pedidos em paralelo, e sem isso eles se atropelariam. Antes de cada login o worker chama page.bringToFront().

Conta o tempo do pedido: 5 bots ≈ 8 minutos, 10 bots ≈ 18 minutos. Durante a espera as contas já logadas seguem vivas — o heartbeat continua renovando os leases delas, e o lease do pedido é renovado junto, então o supervisor não recolhe um pedido que está rodando normalmente.

Um Ctrl+C durante a espera não fica travado: interruptibleSleep acorda cedo.

Reconexão

Ciclo de 15s verificando se a página ainda responde. Caiu, o worker avisa o servidor (que conta as tentativas, para o limite valer mesmo se o worker reiniciar) e reloga.

Dois cuidados que existem por terem quebrado antes:

  • Trava de reentrância. Uma reconexão leva minutos. Sem a trava, a volta seguinte do monitor via a mesma página morta e disparava outra reconexão: as 3 tentativas queimavam em 45 segundos e a conta era descartada em vez de recuperada.
  • Navegador relançado. Se o navegador inteiro morreu, newContext() estourava em toda tentativa e a conta nunca voltava. Hoje ele é relançado com o mesmo proxy da conta.

Fila de requisições ao backend

apiClient.js separa o tráfego em duas faixas:

Faixa O quê Comportamento
Controle heartbeat, claim sem fila, sem retry longo — são idempotentes e sensíveis a tempo
Estado abrir/fechar sessão, resultado fila serial com retry e backoff exponencial

A ordem importa na faixa de estado: um "sessão aberta" chegando depois do "sessão fechada" deixaria bot e proxy presos. Serializando, isso não acontece — e um blip de rede não perde o evento.

Desligamento limpo

No SIGTERM/SIGINT o worker avisa POST /worker/shutdown, e o servidor encerra as sessões, devolve bots e proxies e recoloca os pedidos na fila. Se levar kill -9 e o aviso não sair, o supervisor recolhe depois do timeout de heartbeat. Por isso as units usam TimeoutStopSec=45.


Autenticação

Três credenciais distintas, sem sobreposição:

Quem Como Escopo
Usuário / admin JWT Authorization: Bearer /api/* exceto /api/worker/*
Worker API key X-API-Key /api/worker/*
VPS instalando token de instalação na URL /api/provision/:token/*

JWT de admin não abre rota de worker, e API key não abre rota de admin — há testes cobrindo ambos.

A API key é guardada só como SHA-256, comparada em tempo constante (timingSafeEqual). O texto puro aparece uma única vez, na criação. Perdeu, rotaciona — e a rotação libera na hora o que aquele worker segurava, para a fila não travar.


A frota guarda o banco

A ideia de "nuvem própria" veio junto com sync mútuo, load balance e migração — o desenho de banco distribuído multi-master. Esse caminho não foi seguido, e o motivo é concreto: o dado mais importante aqui é crédito. Duas máquinas aceitando o mesmo pedido com replicação assíncrona produzem gasto duplo, e "resolver o conflito" de saldo significa escolher qual cliente perde dinheiro.

Mas os números da frota apontaram para outra coisa, e essa vale muito:

Fato Número
Banco inteiro 28 MB
Disco ocioso na frota ~1 TB em 7 máquinas
Disco livre na máquina do painel 8,8 GB (81% cheio)
"Backup" que existia cp do .db, na mesma máquina, sem o WAL de 19 MB

Ou seja: não é problema de capacidade nem de escala de leitura — é de redundância. E o backup que existia tinha dois defeitos num comando só: copiar um .db em WAL sem o WAL dele pode produzir um arquivo num estado que nunca existiu, e a cópia morria junto com o servidor que ela deveria salvar.

O desenho que ficou:

  1. Retrato consistente: VACUUM INTO, que roda dentro do SQLite — sai íntegro mesmo com escrita acontecendo, já inclui o WAL e ainda compacta.
  2. Cifrado (AES-256-GCM) antes de sair. O banco tem senha de conta e hash de PIN; espalhar isso em sete máquinas multiplicaria por sete a superfície de um vazamento. A chave vem do .env do painel e nunca sai dele — a VPS guarda um arquivo que ela mesma não lê.
  3. Quem puxa é o worker. O painel não tem credencial para empurrar nada nas VPSs (e não deveria ter), enquanto o worker já fala com ele autenticado por API key a cada dez segundos. A cópia viaja pelo caminho que já existe.
  4. O painel responde "estou protegido?" com evidência: cada worker reporta no heartbeat quais retratos tem no disco. protegido exige a cópia atual em pelo menos duas máquinas — e volta a false assim que um retrato novo é gerado, até a frota alcançar.
  5. A restauração é testada: docs/restaurar-banco.js decifra, roda integrity_check e mostra o que tem dentro (usuários, saldo, contas, sessões). Backup que ninguém restaurou não é backup, é esperança.

BACKUP_INTERVAL_MS (6h) controla o ritmo. O endpoint de download só entrega arquivos no formato que o próprio painel gera — caminho vindo de fora nunca escolhe o que sai do disco.

O que continua fora: CDN caseiro (o painel são ~600 KB de JS e a Cloudflare já está na frente) e réplica de leitura (o banco tem 28 MB; o gargalo da operação são os navegadores, não a consulta).

A nuvem de arquivos (cdn.hyperblet.com)

Sobe arquivo pelo painel, ele fica guardado nos discos ociosos da frota (~1 TB parados em sete máquinas, em dois continentes — Amsterdã e Miami), e o que for marcado como público ganha um endereço em cdn.hyperblet.com.

O que isto é, e o que não é. A borda que entrega ao visitante é a Cloudflare, que já está na frente do domínio. As VPSs são origem e redundância — arquivo que existe em Amsterdã e em Miami não morre com um datacenter. Chamar as VPSs de "edge" seria bonito e mentira: elas ficam atrás do cache, não na frente dele.

Quatro decisões que valem explicação:

  1. A porta é um servidor separado (CDN_PORT, 3305), e não mais uma rota do painel. cdn.hyperblet.com é endereço público; o que responde nele não deve nem saber que existe rota de admin. Ali há um verbo (GET) e um recurso (arquivo público) — não há superfície para autenticação vazar porque não há autenticação.
  2. Privado responde 404, não 403. 403 confirmaria que o id existe, e o id (16 bytes aleatórios) é justamente o que impede varrer o acervo adivinhando. "Público" quer dizer quem tem o link, não listado.
  3. A réplica que viaja é cifrada, inclusive a de arquivo público. Público é sobre o link; não é sobre quem entrar numa VPS poder ler tudo o que já subiu. Quem serve é o painel, a partir da cópia local — a frota guarda para o dia em que essa cópia sumir.
  4. A escolha das máquinas é por disco livre, usando o número que a telemetria já traz. A réplica cai onde sobra espaço em vez de cair sempre nas mesmas, e a frota se equilibra sozinha conforme enche.

E o painel só conta como réplica o que o worker confirmou ter no disco, no heartbeat. "Mandei" não é "chegou".

A tela, e o login que mora no mesmo endereço

cdn.hyperblet.com deixou de ser só entrega: a raiz do mesmo servidor serve a nuvenzinha — login, grade de arquivos, arrastar e soltar, público/privado, link para copiar. HTML e JavaScript na mão, em src-backend/nuvem-web/. O painel já tem React, Vite e Tailwind; trazer isso para uma tela de upload seria um segundo build para manter e um segundo deploy para lembrar. Aqui o deploy é copiar três arquivos.

Não existe cadastro — nem registro, nem convite, nem "esqueci a senha". As contas são uma lista no .env (NUVEM_USUARIOS), e a única forma de criar uma é editando o arquivo na máquina:

node docs/senha-da-nuvem.js thomas     # pergunta a senha e imprime a linha

O que segura quem insiste:

  • senha guardada como hash (scrypt, salt por conta). O .env já guarda segredo pior, mas senha em claro acrescenta o estrago fora daqui — senha de gente se repete em outros lugares. Texto puro ainda funciona, e o boot reclama;
  • duas travas: por (conta, IP) fecha em 8 erros, e por conta vinda de qualquer lugar fecha em 40. A segunda existe porque este projeto compra proxy às dúzias — trocar de IP é barato. O teto global é alto de propósito: baixo demais viraria arma, com qualquer um trancando a conta alheia só errando senha;
  • a trava é gravada no banco, e não num Map. Reiniciar o processo é coisa que se provoca de fora, e um contador em memória volta ao zero junto;
  • senha errada e conta inexistente respondem a mesma frase, no mesmo tempo (o scrypt roda mesmo quando a conta não existe). Sem isso, o cronômetro diria quais nomes valem a pena atacar;
  • sessão em cookie HttpOnly, SameSite=Strict, assinado com HMAC. Nenhum script da página consegue lê-lo, e ele carrega um selo derivado do hash da senha: trocar a senha no .env derruba as sessões abertas na hora.

O preço de juntar o login com o acervo público, dito na cara: até então cdn.hyperblet.com não tinha sessão nenhuma, e por isso um arquivo podia ser servido inline com o Content-Type que quem subiu escolheu. Com cookie no mesmo host isso vira roubo de sessão — bastaria subir um .html público para ter script rodando na origem que guarda o cookie. Então agora existe uma lista branca de tipos que abrem no navegador (imagem raster, vídeo, áudio, texto puro, PDF); o que não estiver nela sai como attachment e application/octet-stream. Lista branca, e não negra: com lista negra, a próxima extensão que o navegador aprender a executar entra sozinha. image/svg+xml fica de fora de propósito — SVG é documento com script dentro, não figura.

Variável Padrão Para quê
CDN_PORT 3305 porta do servidor público (0 ou vazio desliga)
CDN_DIR ./cdn onde os arquivos ficam no painel
CDN_REPLICAS 3 em quantas máquinas replicar
CDN_MAX_BYTES 0 teto por arquivo; 0 é sem limite
CDN_MARGEM_GB 5 quanto de disco tem de sobrar sempre
NUVEM_USUARIOS usuario:hash, separados por vírgula. Sem isto, ninguém entra
NUVEM_SECRET JWT_SECRET assina o cookie de sessão da nuvem
NUVEM_SESSAO_HORAS 12 quanto tempo até pedir a senha de novo
CDN_REPLICAS_DIR pasta do worker onde procurar a réplica cifrada quando falta a cópia local

O que o cluster mudou aqui

O painel deixou de ser uma máquina só, e a nuvem não pode ignorar isso:

As três precisam da mesma lista e do mesmo segredo. NUVEM_USUARIOS tem de estar idêntico nas três — o túnel só liga na líder, então quem responde o login é quem estiver mandando. E o cookie é assinado: com um NUVEM_SECRET diferente em cada máquina, toda eleição (14 a 18 s) derrubaria a sessão de todo mundo. Na prática o melhor é não definir NUVEM_SECRET em produção e deixar cair no JWT_SECRET, que o cluster já exige ser igual nas três.

O acervo não viaja no retrato. O retrato leva a tabela cdn_objetos; os bytes ficam em CDN_DIR da máquina que recebeu o upload. Como o dono troca sozinho, os arquivos ficam espalhados pela frota — e na primeira eleição todo link público responderia 404, com o registro existindo e o arquivo não.

A saída não precisa de rede: a mesma máquina roda o worker, e o worker já guarda a réplica cifrada. Quando falta a cópia local, o painel decifra a réplica sob demanda (garantirLocal), grava a cópia e passa a servir. A chave é a BACKUP_KEY, que o cluster já exige ser igual nas três. Réplica adulterada é recusada pela etiqueta do GCM, e não vira arquivo servido.

node docs/prova-nuvem-web.js sobe o servidor de verdade contra um banco descartável e prova o que não dá para conferir olhando a tela: que o login não entrega informação, que a trava sobrevive a um reinício, que .html e .svg não abrem inline, que privado responde 404, e que apagar produz a ordem de apagar para a frota.

Sem teto por arquivo — e por que isso exigiu reescrever o upload

O acervo é interno, então não há limite de tamanho (CDN_MAX_BYTES=0). Só que apagar o número, sozinho, trocaria "não deixa subir" por "derruba o painel": o corpo inteiro virava Buffer na memória (express.raw) e a cópia cifrada era outro Buffer inteiro em cima — um vídeo de 3 GB pedia perto de 10 GB de RAM no mesmo processo que segura o banco, o supervisor e a eleição do cluster. O teto de 200 MB não era um capricho; era o que escondia isso.

Agora o corpo vai em fluxo direto para o disco, com o sha256 saindo no caminho, e a réplica cifrada é gerada lendo o que acabou de ser gravado. Medido em docs/prova-upload-grande.js: 600 MB entram com 69 MB de crescimento de memória no processo do painel — e a prova ainda apaga a cópia em claro, força a recuperação pela réplica e confere o sha256 byte a byte, porque cifrar em fluxo escreve a etiqueta do GCM por posição e errar ali produz uma réplica que só falha no dia da restauração.

O que continua existindo é a margem de disco (CDN_MARGEM_GB, 5 GB), e ela não é limite de tamanho: é o que impede um upload de encher a partição e levar junto o banco e o painel. Ela é conferida antes de começar e a cada 256 MB recebidos — porque envio em pedaços não manda Content-Length, e sem a segunda checagem passaria por cima da margem até o ENOSPC.

E ainda faltava a borda. Tirar o teto do nosso lado não bastou: quem barra arquivo grande é a Cloudflare, com um 413 que nem chega ao painel. Medido em produção: 150 MB levam 413 dela em 16 segundos, e o HTML de erro vem assinado por ela. É o limite de 100 MB do plano, e vale para o túnel também.

Contornar a borda custaria expor o IP da VPS e perder o TLS — e a máquina que manda troca sozinha, então nem endereço fixo existe. Então arquivo grande sobe em pedaços de 90 MB (uploadPedacos.js), que é o que serviço de arquivo grande faz há anos:

  • os pedaços são anexados em ordem num arquivo só. Guardar cem partes para juntar no fim custaria uma cópia inteira em disco e uma janela ocupando o dobro do que a pessoa subiu;
  • fora de ordem é recusado dizendo qual era o esperado. Aceitar embaralharia o arquivo por dentro, e só o sha256 denunciaria — depois de tudo enviado;
  • reenviar um pedaço que já entrou responde "já tenho este", e não erro: conexão que cai no meio da resposta faz o cliente reenviar, e tratar isso como falha transformaria uma retomada normal em upload perdido;
  • sessão abandonada é varrida no arranque e de hora em hora — bytes parados no disco do painel ninguém vai lá olhar, e disco cheio leva o banco junto.

O sha256 e a cópia cifrada saem da mesma leitura do arquivo montado (o hash vive num Transform no meio do caminho), então finalizar um arquivo de 20 GB não custa lê-lo duas vezes.

O prazo de requisição do servidor da nuvem também caiu (requestTimeout = 0): o Node corta requisição com mais de 5 minutos por padrão, e um arquivo grande numa subida doméstica passa muito disso — o corte chegava como "conexão caiu no meio do envio" depois de meia hora enviando.

A prévia, e o isolamento de cada tipo

Clicar na miniatura abre o arquivo sem sair da nuvem: foto, vídeo (com barra de progresso — o sendFile responde a Range), áudio, PDF, e texto de .txt, .json, .md, .csv, .log. Setas e ← → andam pela grade; Esc fecha.

Quem decide o que pode ser mostrado é a categoria (tipos.js), calculada no servidor — nunca o Content-Type que veio no upload. A diferença importa: a nuvem mora no mesmo endereço que guarda o cookie, e deixar quem sobe escolher como o navegador de quem olha trata o arquivo dele é o buraco que a lista branca do acervo já tinha fechado. A extensão do nome só entra como desempate quando o navegador de quem subiu não soube dizer o tipo — .mkv costuma chegar sem tipo nenhum.

Cada categoria ganha a liberdade que precisa, e nada além:

Categoria Como aparece Por quê
imagem, vídeo, áudio direto na tag não executam nada
texto <pre> com textContent não vira documento: o que nunca é interpretado não roda
página (html, svg) <iframe sandbox> documento com script dentro — origem opaca, sem cookie, e Content-Security-Policy: sandbox na resposta para valer também se abrirem a URL na mão
pdf <iframe> sem sandbox o visualizador embutido já roda fora do alcance do DOM e do cookie, e o atributo sandbox o deixa em branco. Trocar "isolado de novo" por "não mostra nada" não é ganho de segurança
o resto ícone e botão de baixar sem fingir que dá

A miniatura de vídeo é o primeiro quadro, com preload="metadata" e #t=0.1 no endereço: o navegador desenha o pôster sozinho — sem ffmpeg no servidor, sem miniatura gravada em disco e sem baixar o arquivo inteiro.

node docs/prova-previa.js abre a tela num navegador de verdade e confere o elemento criado para cada tipo. Ela pegou dois defeitos que teste de API não pegaria: um style= escrito no template, que a CSP da própria página ignorava em silêncio, e a prévia de PDF nascendo em branco porque o nosso próprio frame-ancestors 'none' barrava o iframe na mesma origem.

Três defeitos que a tela nova revelou

Nenhum deles aparecia enquanto a nuvem só tinha uma página de admin:

  1. A listagem estava embaralhada. ORDER BY id DESC num id de 16 bytes aleatórios não é "mais recente primeiro", é ordem sorteada — a cada upload a grade trocava de ordem e ninguém achava o que tinha acabado de subir. Passou a ser por data.
  2. Acima de 500 arquivos, alguns nunca replicavam. A busca do que falta para cada worker lia "os 500 primeiros" pela mesma ordem sorteada; o que ficasse de fora do sorteio nunca era pedido, e o painel diria "sem cópia" para sempre. Agora o filtro é no SQL, do mais antigo para o mais novo — quem está desprotegido há mais tempo passa na frente.
  3. Apagar não apagava na frota. O comentário prometia que o worker limpava o que o painel parasse de pedir; nada no worker fazia isso. O arquivo saía do painel e continuava vivo em três máquinas, cifrado, para sempre. O heartbeat passou a devolver cdnApagar com os ids que sobraram, e quem apaga é a máquina — o painel não tem (nem quer) credencial para mexer no disco dela.

O painel que assume sozinho

O pedido era "caiu uma, ele já começa a responder em outra, sem ficar configurando". O jeito óbvio de fazer isso é o que gasta crédito duas vezes: cada painel tem o seu banco, e eleição automática sem quórum cria dois donos — o candidato acha que o dono morreu, o dono acha que está vivo, e os dois estão certos do próprio ponto de vista.

A saída foi mudar o que a máquina de espera pode fazer, em vez de tentar acertar quando ela pode assumir:

Ativo Espera
Escreve sim não
Site abre sim sim, em modo consulta
Pedido, compra, admin sim 503 com explicação
Worker pega trabalho sim recusado
/health 200 503 enquanto calado, 200 ao assumir

Recusar o worker é o que fecha a porta da divergência: sem isso, uma máquina de espera entregaria trabalho e abriria sessão que o painel de verdade nunca saberia que existiu.

Com esse desenho, se a automação errar o pior caso é um site que só lê por alguns minutos — e não dois bancos divergindo. Erro que não corrompe nada é erro que pode ser automatizado, e por isso essa parte roda sozinha. A promoção para escrita (POST /admin/painel/assumir) continua sendo um ato.

Detalhes em docs/mudar-painel-de-casa.md.

Segurança

Quem passa pela porta

protect faz quatro perguntas a cada chamada, e não só "a assinatura confere?":

  1. O token é de sessão? O de cadastro de PIN (purpose: 'pin_setup', 10 min) é assinado com o mesmo segredo. Sem essa checagem ele passava como sessão — e dava para pular o PIN, que é o segundo fator depois da verificação do lema, e usar a API inteira, inclusive as rotas de admin.
  2. A conta existe? JWT é autossuficiente: o de uma conta apagada continuava valendo até expirar.
  3. A conta está banida? O banimento só era checado em adminOnly e no pedido de bots. Enquanto isso o banido comprava conta, gastava crédito e abria chamado — por até 30 dias, que era a validade do token. Banir não adiantava nada.
  4. O selo bate? users.token_epoch é a revogação que o JWT não tem. Banir e rebaixar de admin incrementam o selo, e todo token assinado antes morre na hora.

O papel vem do banco, não do token: promover ou rebaixar alguém vale imediatamente, e não no próximo login dele. A sessão caiu de 30 para 7 dias (SESSION_DURATION no .env) — 30 dias era conforto para o cliente e presente para quem roubasse o token.

Bloqueio de IP

services/bloqueio.js guarda a lista em memória e recarrega a cada escrita: a checagem roda em toda requisição, e consultar o banco por requisição para uma lista que muda uma vez por semana seria pagar caro por nada.

O middleware fica antes do rate limit (quem está bloqueado não deve nem consumir a cota de ninguém) e deixa /worker/ e /provision/ de fora de propósito: quem autoriza ali é a API key, e um IP digitado errado derrubaria a frota inteira em vez de um abusado. O painel também recusa bloquear o IP de quem está pedindo — o bloqueio vale para a API toda, e trancar o próprio endereço deixaria a saída só por SSH na VPS.

Bloqueio com prazo (minutos) some sozinho na primeira vez que alguém esbarra nele. A tela fica em Acessos: uma tabela por endereço (visitas, páginas, quem, último acesso) — porque a lista de acessos recentes conta o que aconteceu, não quem fez acontecer, e um endereço com centenas de visitas numa página só se esconde no meio das últimas sessenta linhas.

Cabeçalhos e CORS

cors() sem opção devolvia Access-Control-Allow-Origin: *. Não vazava sessão (o token vai no header, e um site de terceiro não consegue lê-lo), mas abria a superfície pública para automação a partir do navegador de outra pessoa sem motivo: o painel é servido por este mesmo processo, na mesma origem. Agora a origem é fechada, com lista em CORS_ORIGINS para o caso de dev com Vite.

Os cabeçalhos são escritos à mão em vez de helmet — são seis linhas, e uma dependência a menos é uma superfície a menos num processo que tem SSH para a frota inteira. frame-ancestors 'none' importa mais do que parece: o painel dentro de um iframe alheio é clickjacking, com o admin clicando em "Reiniciar a VPS" achando que clica em outra coisa.

O que ainda está aberto

Risco Situação
Senha das contas de bot em texto puro no banco o arquivo é -rw------- e o processo roda como root; falta cifrar em repouso
Admin = SSH na frota inteira uma senha de PIN comprometida abre o console; falta 2FA/TOTP
Segredos no histórico do git senhas de bot e proxies antigos; exige reescrever o histórico ou rotacionar tudo

Referência da API

Base: /api. Health check separado em /health.

Público

POST /login/request        { habbletUsername }        → código para o lema
POST /login/confirm        { habbletUsername }        → JWT ou pinSetupRequired
POST /login/pin/setup      { setupToken, pin }        → JWT
POST /login/pin            { habbletUsername, pin }   → JWT

Usuário (JWT)

GET  /auth/me
GET  /bots/history
POST /bots/request         { roomId, botQuantity, durationMs, preferPersonal }
POST /bots/requests/:id/cancel        botao de panico: tira os bots e devolve o tempo nao entregue

GET    /bots/availability             quanto ele tem, e quanto da casa ainda cabe na cota dele
GET    /bots/pool                     POST /bots/pool          POST /bots/pool/import
DELETE /bots/pool/:id
GET    /bots/loja                     preco, estoque e quanto cabe no pool dele
POST   /bots/loja/comprar             { quantidade }  -> contas viram dele na hora

Admin (JWT + papel admin)

GET    /admin/bots                    POST /admin/bots            POST /admin/bots/import
PUT    /admin/bots/:id/status         DELETE /admin/bots/:id
DELETE /admin/bots                    { escopo }  limpeza em lote do pool da casa
POST   /admin/bots/:id/vender        tira do pool da casa e poe a venda
POST   /admin/vendas/:id/usar        o contrario: da prateleira de volta ao pool
GET    /admin/vendas                  estoque + livro de vendas
POST   /admin/vendas/import           { text }  nick:senha por linha
DELETE /admin/vendas/:id              so tira o que ainda nao vendeu
GET    /admin/users                   PUT  /admin/users/:id       { credits, status, role }
GET    /admin/ips                     POST /admin/ips             { ip, motivo, minutos }
DELETE /admin/ips/:ip

GET    /admin/proxies                 GET  /admin/proxies/stats
POST   /admin/proxies                 PUT  /admin/proxies/:id     DELETE /admin/proxies/:id
POST   /admin/proxies/:id/test        POST /admin/proxies/test-all
POST   /admin/proxies/import          { text, proxy_type }   formato host:porta:usuário:senha

GET    /admin/sessions/stats          GET  /admin/sessions/active
GET    /admin/sessions/history        POST /admin/sessions/:id/kill

GET    /admin/workers                 GET  /admin/workers/stats
GET    /admin/workers/queue           GET  /admin/workers/events
POST   /admin/workers                 { name, capacity }      → devolve a API key UMA vez
PUT    /admin/workers/:id             { name, capacity, enabled, draining }
DELETE /admin/workers/:id
POST   /admin/workers/:id/rotate-key
POST   /admin/workers/:id/release     libera à força o que o worker segura
GET    /admin/workers/runtime         o que está publicado e quem já está rodando
POST   /admin/workers/runtime/publish publica o pacote atual para todos
POST   /admin/workers/:id/update-runtime  baixa no próximo heartbeat, aplica quando ficar sem sessão
POST   /admin/workers/:id/restart     derruba o processo; o systemd sobe de novo
POST   /admin/workers/:id/reboot      reinicia a MAQUINA inteira (30s-2min fora do ar)
POST   /admin/workers/reconcile       força um ciclo do supervisor

GET    /admin/settings                catálogo + valores + de onde cada um veio
PUT    /admin/settings                { valores: { CHAVE: valor, ... } }
DELETE /admin/settings/:key           volta para o .env, ou para o padrão

POST   /admin/workers/:id/install-token  { panelUrl, vncPublic }  → comando curl
POST   /admin/workers/:id/provision      { host, port, username, password, panelUrl, vncPublic }
GET    /admin/workers/provisions         GET /admin/workers/provisions/:id

Worker (X-API-Key)

POST /worker/register              { hostname, platform, version, pid, capacity }
POST /worker/heartbeat             { sessionIds, requestIds, capacity, metrics }
POST /worker/claim                 → { job } ou { job: null, reason }
POST /worker/sessions              { requestId, botId }        idempotente
POST /worker/sessions/:id/close    { reason, status }
POST /worker/sessions/:id/reconnecting   → { giveUp }
POST /worker/sessions/:id/recovered
POST /worker/requests/:id/rotate   { botId, proxyId, reason, attempt } → { slot } ou { slot: null }
POST /worker/requests/:id/result   { successfulBots, failedBots, error }
POST /worker/shutdown              { reason }

Instalação (token na URL, sem JWT)

GET /provision/:token/install.sh          script de bootstrap
GET /provision/:token/worker-files.json   pacote do worker + .env pronto

Motivos de claim sem trabalho: fila_vazia, sem_capacidade, draining, cedendo_a_vez, corrida_perdida, contas_insuficientes, worker_indisponivel.


Deploy: VPS principal

Estado atual da 144.202.46.116 (Debian 13, 4 vCPU, 11 GB RAM).

Serviços

Unidade Função
painelbots-backend API + painel na porta 3169
painelbots-worker worker Camoufox, capacidade 4
painelbots-display Xvfb :99, tela dos navegadores
painelbots-wm openbox no :99
painelbots-vnc x11vnc no :99, preso ao localhost
nginx serve o build e faz proxy de /api
cloudflared túnel para hyperblet.com

Projeto em /home/admin/Desktop/a/painelhbt, rodando como usuário admin.

A camada de rede

Cloudflare (TLS, hyperblet.com)
    │  túnel de saída
cloudflared
    │  loopback
nginx :80
    ├── /            → frontend/dist  (SPA, assets com cache de 1 ano)
    ├── /api/        → 127.0.0.1:3169
    └── /health      → 127.0.0.1:3169/health

Config em /etc/nginx/sites-available/painelbots. Dois detalhes que importam:

  • real_ip_header CF-Connecting-IP — sem isso o rate limit de login veria todo mundo como se fosse o próprio cloudflared, e cinco tentativas erradas de qualquer pessoa bloqueariam o login de todas.
  • proxy_read_timeout 900s — a instalação de VPS por SSH mantém a conexão aberta por até 15 minutos; com o timeout padrão de 60s o navegador levaria 504.

O frontend usa caminho relativo (/api), então a mesma build funciona pelo domínio, pelo IP ou em desenvolvimento.

Isso era http://localhost:3169/api fixo, o que só funcionava com o navegador na mesma máquina. De um IP público, cada visitante mandava os requests para o próprio localhost dele.

O backend também serve frontend/dist por conta própria (fallback de SPA incluso). Com o nginx na frente isso vira redundância útil: dá para bater direto em 127.0.0.1:3169 para depurar sem passar pela borda.

A rota do túnel

O ingress da Cloudflare pode apontar para dois lugares, e os dois funcionam:

Rota O que acontece
http://localhost:3169 vai direto no Express, que serve a build e a API
http://localhost:80 passa pelo nginx: build estática com cache de 1 ano e /api no proxy

Em produção está em 3169. Trocar para 80 tira o tráfego estático do event loop do Node (que também roda o supervisor e atende os workers) e faz os assets serem cacheáveis na borda. Para o volume atual, qualquer uma serve.

IP real do visitante

Atrás do túnel toda conexão chega do loopback. Sem tratamento, req.ip seria 127.0.0.1 para todo mundo — e o rate limit de login, que é por (usuário, IP), degeneraria em "por usuário": qualquer pessoa trancaria a conta de qualquer outra com 5 PINs errados.

Duas peças resolvem, e valem para as duas rotas:

  • app.set('trust proxy', 'loopback') no Express — confia só em 127.0.0.1/::1, então um X-Forwarded-For forjado de fora não é aceito.
  • Um middleware que prefere o header CF-Connecting-IP, com o req.ip como reserva quando o painel é acessado sem passar pela Cloudflare.

Para conferir, GET /health devolve o IP que o backend está enxergando:

curl -s https://hyperblet.com/health
# {"ok":true,"uptime":16.2,"clientIp":"2804:4f60:..."}   ← seu IP, não 127.0.0.1

Ligar o túnel

sudo /root/ativar-tunnel.sh <TOKEN>

O token sai de Cloudflare → Zero Trust → Networks → Tunnels → Create a tunnel (tipo Cloudflared). No painel da Cloudflare, aponte as rotas públicas para:

Hostname Serviço
hyperblet.com HTTPlocalhost:80
www.hyperblet.com HTTPlocalhost:80

O script remove instalação anterior, instala o serviço systemd, mostra o log e testa o que o túnel vai servir. Verificação: curl -I https://hyperblet.com/health.

Display :99, não :0

O worker precisa de um display para rodar o navegador visível (CAMOUFOX_HEADLESS=false). Usamos Xvfb num display dedicado, e não a sessão do console nem a do RDP:

  • O :10 do xrdp some quando você desconecta — como serviço, quebraria sozinho.
  • Autologin do LightDM no :0 não funciona nesta imagem de Debian 13: o /etc/lightdm/lightdm.conf é lido depois do conf.d/ e sobrepõe a configuração. Onde funciona, dá conflito de sessão quando o mesmo usuário também entra por RDP.

O :99 não depende de ninguém logado, sobe em segundos e não briga com nenhuma sessão interativa. O openbox está lá porque, sem gerenciador de janelas, o navegador não recebe foco direito — e página sem foco tem o carregamento estrangulado, exatamente o que trava o login.

Portas e firewall

Porta Serviço Exposição
22 SSH pública
3389 xrdp pública
80 nginx só loopback (o ufw não libera; quem chega é o cloudflared)
3169 backend só loopback depois que o túnel estiver de pé
5900 VNC dos bots só localhost

O nginx escuta em 0.0.0.0:80, mas o ufw não abre a porta — de fora ela é inalcançável, e o cloudflared chega por loopback.

Com o túnel funcionando, feche o acesso direto ao backend. Deixá-lo aberto significa uma porta que contorna a Cloudflare — sem TLS e sem WAF:

ufw delete allow 3169/tcp

Ver os bots rodando

ssh -L 5900:localhost:5900 root@144.202.46.116
# depois, cliente VNC em localhost:5900

Senha em /root/.painelbots-vnc-senha.

Atualizar o código

cd /home/admin/Desktop/a/painelhbt
git pull                       # ou scp dos arquivos alterados
npm install --omit=dev
cd frontend && npm install && npx vite build && cd ..
chown -R admin:admin .
chmod -R o+rX frontend/dist          # o nginx roda como www-data
systemctl restart painelbots-backend painelbots-worker

O npm install como root deixa os arquivos de root — daí o chown, já que os serviços rodam como admin.


Deploy: worker novo em VPS

Debian 13 limpa. Dois caminhos, mesmo resultado.

Pelo painel (recomendado)

Administração → Workers → Instalar em VPS

  1. A URL do painel já vem preenchida com PANEL_PUBLIC_URL (https://hyperblet.com)
  2. Aba 🚀 Instalar agora (SSH): IP, porta, usuário, senha
  3. O backend entra por SSH e roda o bootstrap, com log ao vivo na tela

O worker novo sai com BACKEND_URL=https://hyperblet.com/api no .env — só faz chamadas de saída, então não precisa de túnel, nginx nem porta aberta. Um worker não expõe nada.

O campo continua editável. O servidor manda o endereço canônico porque o painel pode estar aberto por um caminho que a VPS não alcança (IP interno, localhost, túnel local) — nesse caso window.location daria a URL errada.

A senha é usada só para abrir a conexão e não é gravada em lugar nenhum.

Manual

Aba 📋 Comando manual gera:

curl -fsSL https://painel/api/provision/<token>/install.sh | sudo bash

Válido por 30 minutos, uso único.

O que o script faz

  1. Verifica Debian, instala utilitários base
  2. Node 22 via NodeSource (pula se já houver ≥ 20)
  3. Bibliotecas do Firefox/Camoufox — instala pacote a pacote e tolera nome que mudou entre versões (a transição t64 do trixie renomeou várias libs)
  4. Xvfb :99 + openbox + x11vnc
  5. Usuário painelbot, worker em /opt/painelbots-worker
  6. npm install e download do Camoufox (~150 MB)
  7. Units systemd: display, WM, worker e VNC — todas com Restart=always
  8. Sobe tudo e imprime o resumo com o comando de acesso VNC

Rodar de novo na mesma VPS atualiza o worker; não duplica nada.

O pacote é enxuto

worker.js, runtime.js, apiClient.js e um package.json com quatro dependências. Sem better-sqlite3 nem express — o worker não toca o banco, e isso evita compilar node-gyp na VPS.

⚠️ Gerar a instalação rotaciona a API key

O banco só guarda o hash, então não há como recuperar o texto puro para colocar no script. A chave é gerada na hora do convite e vive só dentro do token até a VPS baixar o pacote.

Consequência: se aquele worker já estiver rodando em outra máquina, aquela instalação para de funcionar. É o comportamento correto ao mandar um worker para uma VPS nova; para reinstalar um existente, saiba disso antes.

Se falhar

O log completo fica no painel. Erros comuns:

  • All configured authentication methods failed — a imagem veio com PasswordAuthentication no no sshd_config.
  • não consegui baixar o worker — a VPS não alcança a URL do painel.

Pagamento: Mercado Pago (Pix)

A regra que organiza o driver: o navegador do cliente nunca fala com o Mercado Pago, e o servidor nunca acredita no que o cliente conta.

O front recebe do nosso backend apenas o que precisa desenhar — o QR (imagem base64 pronta) e o copia-e-cola. Não há SDK do MP na página, não há chave pública embarcada e não existe rota que aceite "o cliente disse que pagou".

A confirmação tem dois passos, e o segundo é o que importa

  1. Assinatura. O MP assina um manifest (id:...;request-id:...;ts:...;) com HMAC-SHA256 e o segredo do painel. Sem assinatura válida, a requisição é descartada — senão qualquer um que descobrisse a URL creditaria a própria conta. A comparação é timingSafeEqual, não ===: comparação que sai no primeiro byte diferente vaza a assinatura correta byte a byte. E assinatura com mais de 10 min é recusada, contra reenvio de um webhook legítimo antigo.

  2. O corpo do webhook é ignorado como fonte de verdade. Ele só diz "o pagamento X mudou"; quem responde "mudou para o quê" é uma consulta nossa a GET /v1/payments/{id}, autenticada com o access token. É a diferença entre confiar num recado e conferir na fonte.

approved credita. in_process e authorized são "ainda não" — tratá-los como pagos daria crédito por pagamento que ainda pode ser recusado.

Rede de segurança

Webhook se perde: rede cai, deploy acontece no segundo errado, o MP desiste depois de N tentativas. refreshPending() roda no ciclo do supervisor e consulta as cobranças pendentes direto no gateway — antes de expireStale(), senão uma cobrança paga cujo webhook se perdeu seria marcada como vencida pouco antes de alguém perguntar.

O crédito continua entrando por um caminho só: settle(), idempotente por credited_at. O MP reenvia o mesmo webhook várias vezes; reenvio não credita de novo.

Prazo do Pix

CHECKOUT_TTL_MS (padrão 5 min) é enviado ao MP como date_of_expiration e guardado como expires_at na nossa transação. Os dois têm que ser o mesmo número. Sem mandar o prazo, o MP usa o dele (30 min) enquanto o painel expira em 5 — e a divergência falha do pior jeito possível: o cliente paga um QR que o painel já deu como vencido.

Limite de requisições

middlewares/rateLimit.js, janela deslizante em memória, chaveado pelo IP real (cf-connecting-ip) — atrás do túnel, req.ip é sempre o loopback do nginx e o limite viraria global: um cliente abusando derrubaria todo mundo.

Escopo Limite Por quê
login 12 / 10 min o bloqueio por usuário+IP já existe no banco; este fecha a porta de varrer vários usuários do mesmo IP
checkout 12 / 10 min cada tentativa cria cobrança de verdade no gateway
pedido 20 / min cobra crédito e mexe na fila
webhook 600 / min generoso: quem manda é o gateway e ele reenvia em rajada
escrita 90 / min teto geral para POST/PUT/DELETE
leitura 600 / min telas que atualizam sozinhas fazem muita leitura
track 240 / min dispara a cada troca de página

O teto geral por método é o chão para rotas novas: esquecer de pensar em limite passa a ser seguro por padrão.

/worker/ e /provision/ ficam de fora. Três workers batendo heartbeat a cada 10s e pedindo trabalho a cada 5s passam de qualquer teto sensato, e dois atrás do mesmo NAT contariam como um. Quem autoriza ali é a API key.

O webhook é generoso de propósito. Quem barra notificação falsa é a assinatura, não o limite. Apertar aqui só faria o gateway desistir de avisar um pagamento real — trocaria um risco que não existe por um prejuízo que existe.

Isto não é proteção contra DDoS: volume morre na Cloudflare. Serve para o cliente que aperta o botão vinte vezes, para o script que tenta adivinhar PIN, e para não deixar ninguém torrar a cota da API do gateway.

Configuração

Onde O quê
.env do servidor MERCADOPAGO_ACCESS_TOKEN, MERCADOPAGO_WEBHOOK_SECRET
Painel → Configurações PAYMENT_PROVIDER = mercadopago ou manual
Painel do Mercado Pago webhook apontando para {PANEL_PUBLIC_URL}/api/payments/webhook/mercadopago

Os segredos ficam no .env e não na tabela de configuração — segredo não se edita por HTTP. PAYMENT_PROVIDER é configuração normal: trocar para manual é o plano B se o gateway cair, e não afeta cobranças já abertas (cada uma lembra com qual gateway nasceu).

A chave pública do MP não é usada em lugar nenhum: ela existe para o SDK de cartão no navegador, e com Pix pelo backend esse SDK não entra na página.


Variáveis de ambiente

As configurações operacionais (preço, duração, leases, rotação) saíram daqui: agora moram no painel, em Configuração. O que sobrou no .env é o que não deve ser editável por HTTP — segredo, caminho de arquivo e porta — mais os valores que servem de base no primeiro boot.

.env fica em src-backend/.env. O backend carrega o arquivo ao lado dele, não o do diretório onde o processo subiu — sem isso, npm start da raiz ignorava o .env e o servidor subia na porta 3000.

Servidor

Variável Padrão Para quê
PORT 3000 porta do backend
HOST 0.0.0.0 interface; sem domínio, o acesso é pelo IP público
JWT_SECRET obrigatória
DB_PATH src-backend/habblet_painel.db caminho do SQLite
INSTALL_TOKEN_TTL_MS 1800000 validade do convite de instalação
PANEL_PUBLIC_URL endereço canônico (https://hyperblet.com); preenche a instalação de VPS

Worker

Variável Padrão Para quê
BACKEND_URL http://localhost:3169/api API do painel (com /api)
WORKER_API_KEY obrigatória, gerada no painel
WORKER_NAME hostname precisa bater com o cadastro
WORKER_CAPACITY 5 sessões simultâneas
WORKER_LOGIN_DELAY_MS sobrepõe o valor vindo do servidor

Infra (config/infra.js)

Variável Padrão Para quê
CREDIT_MINUTES 10 minutos de 1 bot por crédito
CREDIT_PRICE_CENTS 50 R$ 0,50 o crédito
SESSION_MIN_MS 300000 5 min, o mínimo por pedido
SESSION_MAX_MS 3600000 1h, o máximo por pedido
SESSION_STEP_MS 300000 o passo do seletor
SESSION_DURATION_MS 1800000 30 min, a janela sugerida
COST_PER_BOT 10 só para estornar pedido anterior ao preço por tempo
WORKER_HEARTBEAT_INTERVAL_MS 10000 frequência do heartbeat
WORKER_TIMEOUT_MS 35000 sem sinal por esse tempo = worker morto
REQUEST_LEASE_MS 120000 posse de um pedido
SESSION_LEASE_MS 45000 posse de uma sessão
MAX_REQUEST_ATTEMPTS 3 entregas frustradas antes de falhar
SUPERVISOR_INTERVAL_MS 10000 ciclo do watcher
DISPATCH_FAIRNESS_WINDOW_MS 20000 janela de cessão do round-robin
WORKER_POLL_INTERVAL_MS 5000 frequência do claim
MAX_RECONNECT_ATTEMPTS 3 tentativas de religar uma sessão
LOGIN_DELAY_MS 120000 intervalo entre logins
PROXY_MAX_ACCOUNTS 2 contas por proxy

Camoufox

Seletores e tempos do fluxo de login. Ver src-backend/.env.example — os mais usados são CAMOUFOX_HEADLESS, CAMOUFOX_LOGIN_URL, CAMOUFOX_CLIENT_URL, CAMOUFOX_LOGIN_TIMEOUT_MS e CAMOUFOX_PRE_LOGIN_CHECKBOX_SELECTOR.


Operação

# Estado
systemctl status painelbots-backend painelbots-worker
systemctl is-active painelbots-{display,wm,backend,worker,vnc} nginx cloudflared

# Logs
journalctl -u painelbots-backend -f
journalctl -u painelbots-worker -f
journalctl -u painelbots-worker -n 100 --no-pager
journalctl -u cloudflared -f
tail -f /var/log/nginx/painelbots.{access,error}.log

# Reiniciar
systemctl restart painelbots-worker
systemctl restart painelbots-backend    # reconcilia o estado ao subir

# Display dos bots
sudo -u admin DISPLAY=:99 xdpyinfo | grep dimensions

# Borda
nginx -t && systemctl reload nginx     # valida antes de recarregar
curl -I https://hyperblet.com/health    # ponta a ponta, pelo túnel
curl -s http://127.0.0.1/health        # só o nginx
curl -s http://127.0.0.1:3169/health   # só o backend

Backup

O banco é um arquivo só. Em WAL, copie os três:

cd /home/admin/Desktop/a/painelhbt/src-backend
sqlite3 habblet_painel.db ".backup /root/backup-$(date +%F-%H%M).db"

Diagnóstico rápido

Sintoma Onde olhar
Worker aparece offline journalctl -u painelbots-worker; confira BACKEND_URL e a API key
Fila parada com worker online aba Fila no painel; veja last_error e attempts
Sessão presa como ativa ⟳ Reconciliar, ou espere o ciclo de 10s
Contas "sem proxy" card 🛰️ Proxies: o pool encostou na capacidade
Navegador não abre systemctl status painelbots-display; o :99 está no ar?
Painel abre mas a API falha teste as três camadas: :3169:80 → domínio
502 no domínio nginx de pé? backend de pé? journalctl -u nginx
Domínio não resolve systemctl status cloudflared e as rotas no painel da Cloudflare
Login bloqueia todo mundo curl /health e veja o clientIp: se vier 127.0.0.1, o IP real não está passando

Controles do painel

  • Drenar — para de receber pedidos novos, termina o que já está rodando
  • Desativar — imediato: encerra sessões e devolve os pedidos à fila
  • Liberar — solta à força o que o worker segura (use quando ele travou)
  • Rotacionar chave — invalida a atual na hora; atualize o .env e reinicie
  • ⟳ Reconciliar — força um ciclo do supervisor

Desenvolvimento local

npm install
cd frontend && npm install && cd ..

# Terminal 1 — backend
npm start                    # src-backend/.env define a porta

# Terminal 2 — frontend com hot reload
cd frontend && npm run dev   # o Vite faz proxy de /api para o backend

# Terminal 3 — worker (precisa de WORKER_API_KEY no .env)
npm run worker

O vite.config.js encaminha /api para http://localhost:3169 (sobrescreva com VITE_BACKEND_ORIGIN). Em produção não há proxy: o backend serve o build e a API fica na mesma origem.

Para apontar o backend a outro banco durante testes, use DB_PATH.


Controle da operação

Barra no topo da tela de Workers, com o estado antes dos botões — parar sem saber quantos bots estão em sala é decidir no escuro.

Cancelar um pedido

Cancela o pedido do cliente inteiro, não a linha clicada: um pedido pode ser vários registros (os pedaços da divisão e as reposições nascidas deles). Cancelar só uma parte deixaria as irmãs rodando, e o cliente veria metade dos bots saírem da sala sem explicação.

O estorno tem duas partes, porque são dois prejuízos diferentes:

Situação Devolve
Bot que nunca entrou o crédito inteiro dele
Bot que entrou e sai antes da hora a fração do tempo que faltava

Cancelamento não gera reposição. O motivo usado (cancelado_pelo_admin) fica fora de MOTIVOS_DE_INFRA de propósito: repor um pedido que alguém mandou cancelar seria desfazer a ordem. Um pedido completed cujos bots são cancelados vira cancelled — deixá-lo como concluído diria ao cliente que ele recebeu o que pediu, o que deixou de ser verdade no momento em que os bots saíram da sala.

Parar a frota

Dois modos, e a diferença é o que acontece com o que o cliente já pagou:

O que faz O tempo pago
⏸ Pausar workers param de pegar trabalho, sessões caem vira reposição e espera na fila — ao retomar, os bots voltam sozinhos
■ Parar e cancelar além disso, cancela a fila inteira vira crédito de volta

Pausar é o modo de janela de manutenção: parada_geral é falha de infra, então quem pagou por 1h e recebeu 15 min tem direito à volta. Parar e cancelar usa cancelado_pelo_admin, e aí ninguém volta.

Em nenhum dos dois o worker é desligado: draining é reversível de um clique, enquanto enabled = 0 exigiria religar cada um na mão.

Uma armadilha que só apareceu no teste: um pedido que nunca chegou a ser reivindicado tem as contas reservadas apenas pela SALA — current_request_id ainda é NULL, porque o dono só é marcado no claim. Liberar só por dono deixava essas contas presas para sempre ao cancelar um pedido que ainda estava na fila.


Console SSH pelo painel

Botão ⌘ SSH no card de cada worker: abre uma janelinha por cima da tela com um shell na máquina. Existe porque o problema mais comum de operação se resolve com um comando só — e abrir terminal, achar o IP e lembrar a senha custa mais que o comando.

Não é um terminal interativo: cada comando é uma execução isolada com timeout, então htop, vim e prompt de senha do sudo não funcionam. Para systemctl, journalctl, cat e ls é exatamente o que se quer. Há atalhos para status, reinício, logs, versão instalada e disco/memória.

Isto é um shell root, e as decisões de segurança são deliberadas:

Controle Por quê
Só admin (JWT + papel) mesma porta de entrada do resto da administração
A senha nunca é gravada chega uma vez, abre a conexão e some; quem a guarda é o socket do ssh2, na memória do processo
Sessão morre por inatividade (10 min) console esquecido aberto é porta aberta
Todo comando na auditoria, com quem executou é o que permite responder "quem fez isso"
A saída NÃO é auditada ela pode conter chave, token, conteúdo de .env — gravar isso levaria segredo para dentro do banco do painel
Sem lista de comandos proibidos é um shell root de propósito; uma blocklist daria sensação de proteção sem proteger (bash -c contorna qualquer lista). O controle real é admin-only + auditoria

O que isto significa na prática: um token de admin vazado deixa de ser "mexer nos bots" e passa a ser root nas VPSes. O painel só é alcançável pelo túnel (sem porta exposta) e o login é PIN com scrypt e bloqueio por tentativa — mas vale saber que a superfície mudou.

Reinicia o backend, acabam todas as sessões de console: elas vivem só em memória.


Armadilhas conhecidas

Atualização automática dos workers

Publicar código novo era: deploy no painel, e depois clicar "Atualizar runtime" em cada worker, um por um. Fácil de esquecer — e um worker esquecido roda código antigo por dias sem ninguém perceber.

A revisão deixou de ser algo que alguém aperta e virou uma propriedade do código. O servidor calcula a impressão digital (sha256 dos arquivos do manifest, sem o .env) do pacote do worker a cada ciclo do supervisor; mudou o conteúdo, mudou a impressão, e todo worker é marcado para atualizar.

deploy → arquivos mudam → impressão muda → revisão sobe → workers baixam e
reiniciam quando ficarem sem sessão

O .env fica de fora do hash de propósito: um worker não deve reinstalar tudo porque alguém ajustou a capacidade dele. E o cálculo é cacheado por mtime, então o caso comum (nada mudou) não lê disco nem calcula hash.

O melhor efeito colateral: nada mudou no worker. Ele já sabia fazer "a revisão subiu → baixo o pacote e reinicio quando estiver ocioso". Então até um worker rodando um runtime antigo obedece a isto — que é justamente quem mais precisa.

Revisão x impressão digital. São coisas diferentes e a distinção importa:

O que responde
runtime_revision qual pacote o servidor mandou
reported_runtime_revision qual pacote o worker PEDIU
runtime_fingerprint qual código o worker está REALMENTE rodando

Só a impressão prova que a atualização pegou. Um worker antigo, que ainda não sabe reportá-la, cai para a comparação por revisão.

Desligar em Configurações → Atualização (WORKER_AUTO_UPDATE). Desligado, o painel detecta o pacote novo, avisa que há um esperando, e não toca em worker nenhum até alguém clicar em "Publicar para todos".

Uptime: dois números, não um

O painel mostra o uptime do processo e o da máquina, e a diferença entre eles é o diagnóstico:

Processo Máquina Leitura
novo velha o worker caiu e o systemd levantou
novo nova a VPS reiniciou
sempre em segundos qualquer laço de crash

Vai no heartbeat, não só no registro: um número que só chega no arranque fica velho na tela justamente quando alguém está olhando para diagnosticar.

O servidor também conta reinícios: se o uptime do processo cai entre um heartbeat e outro, é outro processo — ele morreu e voltou. É o sinal mais direto de worker instável, e sem isso ele só apareceria garimpando log. Com 3+ reinícios e menos de 5 min de pé, o card avisa "parece estar caindo e voltando".

Worker desatualizado sai da fila até atualizar

A atualização de runtime só é aplicada quando o worker fica sem sessão — o que é o certo, porque bot em sala não é derrubado por deploy. O problema é que um worker movimentado nunca chega a ficar vazio: ele termina uma sessão e já pega outro pedido, para sempre.

Por isso listDispatchableWorkers() tira de circulação quem tem atualização pendente. Ele sai da lista inteira, não só do próprio claim — continuar contando como destino faria o round-robin ceder a vez para ele e a divisão reservar uma cota para ele, ou seja, trabalho parado esperando quem não vai pegar.

A guarda importa mais que a regra: ele só sai se sobrar quem sirva. Com a frota inteira desatualizada — o estado normal logo depois de um deploy — ninguém pegaria nada e a fila travaria. Entregar vale mais que estar na última versão.

Worker que recusa atualizar o runtime

Sintoma: o painel mostra runtime_revision maior que reported_runtime_revision e o log do worker repete, indefinidamente:

[worker] Atualização 5 aguardando 0 sessão(ões) e 2 pedido(s) terminarem.

Zero sessões e ainda assim "pedidos em andamento" é a assinatura do problema: o Map de pedidos do worker ficou com entradas mortas. Elas só eram removidas quando o próprio worker fechava uma sessão pelo caminho normal — sessão encerrada por ordem do servidor, lease vencido ou browser morto deixava a entrada presa para sempre. Corrigido por sweepJobs(), que varre tudo a cada heartbeat.

A armadilha de verdade é que a correção vem dentro da atualização que o worker recusa aplicar. Por isso existe POST /admin/workers/:id/restart (botão "Reiniciar" no painel): o worker sai limpo e o Restart=always do systemd o traz de volta em segundos, já com o runtime novo.

O botão só funciona em worker cujo runtime já entende a ordem. Um worker parado numa revisão anterior a essa precisa de systemctl restart painelbots-worker por SSH, ou de uma reinstalação pelo painel — uma vez só.

Timestamp do SQLite é UTC sem fuso. CURRENT_TIMESTAMP grava YYYY-MM-DD HH:MM:SS. Jogado direto no Date() do Node, é interpretado como horário local — no Brasil, 3 horas de diferença, e o supervisor mataria (ou nunca mataria) sessões na hora errada. Sempre use services/time.js (toMs, nowIso, isoIn, ageMs), nunca new Date(valorDoBanco).

Turnstile do Cloudflare usa Shadow DOM fechado. O checkbox não aparece em dump de DOM, nem com frame.locator(). O iframe costuma renderizar como 1×1 (modo managed) e resolve sozinho. solveTurnstileClick() tenta quatro estratégias de localização do container e clica por coordenadas; se falhar, espera passivamente pelo token.

O aria-label do Turnstile muda entre "Verify you are human" e "Confirme que é humano". Por isso o clique por coordenadas é mais confiável que o seletor.

O backend é processo único. O SQLite não suporta duas instâncias do backend escrevendo no mesmo arquivo — as transações de claim quebrariam. Vários workers, sim; vários backends, não. Para escalar o backend, migre para Postgres antes.

PowerShell não aceita && como separador; use ;.

Um Camoufox por conta consome RAM. Um pedido de 5 bots são 5 instâncias de Firefox, não uma. Dimensione WORKER_CAPACITY pela memória da máquina.


Testes

Suítes em scratchpad/ (fora do repositório), rodadas com DB_PATH apontando para um banco descartável:

Suíte Cobre
infra-test.js fila, round-robin, leases, morte de worker, estornos, proxies por conta
runtime-test.js intervalo entre logins, navegador por conta, reconexão, desligamento
http-test.js API ponta a ponta: autenticação, claim, sessões, drenagem, shutdown
provision-test.js token de instalação, script gerado (bash -n), pacote, rotação de chave