- JavaScript 73.9%
- TypeScript 24.1%
- PowerShell 0.9%
- CSS 0.9%
- HTML 0.2%
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> |
||
|---|---|---|
| .vscode | ||
| docs | ||
| frontend | ||
| motor2 | ||
| scripts/windows | ||
| src-backend | ||
| .gitignore | ||
| .lumi-memory.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| results (1).txt | ||
| turnistilefodase.js | ||
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
- Configuração
- Regras de negócio
- Venda de contas
- Modelo de dados
- Distribuição de trabalho
- O supervisor
- Consumo de banda
- Proxies
- O worker por dentro
- Autenticação
- Segurança
- Referência da API
- Deploy: VPS principal
- Cloudflare Tunnel
- Deploy: worker novo
- Pagamento: Mercado Pago
- Variáveis de ambiente
- Operação
- Desenvolvimento local
- Controle da operação
- Console SSH
- Armadilhas conhecidas
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:
- Worker morre → linhas ficavam
activepara sempre, bots presos embusy, contadores de proxy nunca zeravam. Não havia reconciliação nenhuma. - 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.
- 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 norequire— a edição pelo painel não teria efeito nenhum até reiniciar o processo. Useconst cfg = require('../config/infra')e acessecfg.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ço — JWT_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_quantityda linha ao entregar um pedaço a um worker. Enquantoreserved_creditsficava inteiro nela,custoPorBotpassava a ler "12 créditos por 2 bots" num pedido de 8 — e o irmão nascia semreserved_credits, caindo noCOST_PER_BOTantigo 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 herdamduration_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:
- Estoque (
contas_a_venda, linhas comsold_at IS NULL). Abastecido em Central de bots › Contas à venda, com o mesmo.txtde sempre (nick:senhapor linha). Nick que já existe embot_accountsé recusado na importação: o campo é UNIQUE lá, e a venda quebraria na frente do cliente. - Pool público, e só até
PUBLIC_POOL_SALE_PCTdele (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
completede só então chamava o estorno, que exigia statusprocessing. O estorno parcial nunca acontecia. HojerefundBots()acumula emrefunded_creditse 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_idmarca 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 Turnstiletem 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 FIRST — a ú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:
- a mensagem não casa com nenhuma pista (a culpa era palpite);
- a mesma assinatura de erro atingiu ao menos 3 contas diferentes em 10 min;
- 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
requestIdsdos 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:
- Primeiro acesso / reset:
POST /login/requestgera um códigoHBT-######que o usuário coloca no lema do Habblet.POST /login/confirmconsultaapi.habblet.citye valida. Confirmado, define um PIN de 6 dígitos. - Depois:
POST /login/pincom nick e PIN.
- PIN guardado com
scrypt+ salt, comparado comtimingSafeEqual. - 5 tentativas erradas bloqueiam por 15 minutos, por par (usuário, IP).
- Sessão JWT dura 30 dias. Papéis:
membereadmin.
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 porWORKER_TIMEOUT_MS, o worker perde tudo que segurava.proxy_blocks.in_use— cache, derivado deproxy_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_accountseproxy_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:
- Quem está proporcionalmente mais vazio tem prioridade.
- 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 (~300–500 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 agora — browsers 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 30s–2min |
| 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 — paraproxy_blocks.in_usecongelar no último número e o painel mostrar proxy ocupado sem nada rodando, indefinidamente. E o log só diziaErro no ciclo, sem dizer onde. Hoje cada passo é embrulhado porpasso(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 é:
in_use_real— uma conta por proxy enquanto houver proxy livre (regra de fingerprint, mais importante que qualquer balanceamento);pressao— fração da franquia já gasta, arredondada em faixas de 1%;carga— sessões vivas ÷ tamanho do bloco.last_lease_at— quem 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", porqueproxy_blocksjá é 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 |
só /api/worker/* |
| VPS instalando | token de instalação na URL | só /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:
- Retrato consistente:
VACUUM INTO, que roda dentro do SQLite — sai íntegro mesmo com escrita acontecendo, já inclui o WAL e ainda compacta. - 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
.envdo painel e nunca sai dele — a VPS guarda um arquivo que ela mesma não lê. - 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.
- O painel responde "estou protegido?" com evidência: cada worker reporta
no heartbeat quais retratos tem no disco.
protegidoexige a cópia atual em pelo menos duas máquinas — e volta afalseassim que um retrato novo é gerado, até a frota alcançar. - A restauração é testada:
docs/restaurar-banco.jsdecifra, rodaintegrity_checke 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:
- 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. - 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.
- 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.
- 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
.envjá 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.envderruba 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 |
<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:
- A listagem estava embaralhada.
ORDER BY id DESCnum 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. - 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.
- 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
cdnApagarcom 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?":
- 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. - A conta existe? JWT é autossuficiente: o de uma conta apagada continuava valendo até expirar.
- A conta está banida? O banimento só era checado em
adminOnlye 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. - 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ópriocloudflared, 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/apifixo, o que só funcionava com o navegador na mesma máquina. De um IP público, cada visitante mandava os requests para o própriolocalhostdele.
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ó em127.0.0.1/::1, então umX-Forwarded-Forforjado de fora não é aceito.- Um middleware que prefere o header
CF-Connecting-IP, com oreq.ipcomo 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 |
HTTP → localhost:80 |
www.hyperblet.com |
HTTP → localhost: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
:10do xrdp some quando você desconecta — como serviço, quebraria sozinho. - Autologin do LightDM no
:0não funciona nesta imagem de Debian 13: o/etc/lightdm/lightdm.confé lido depois doconf.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
- A URL do painel já vem preenchida com
PANEL_PUBLIC_URL(https://hyperblet.com) - Aba 🚀 Instalar agora (SSH): IP, porta, usuário, senha
- 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.locationdaria 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
- Verifica Debian, instala utilitários base
- Node 22 via NodeSource (pula se já houver ≥ 20)
- Bibliotecas do Firefox/Camoufox — instala pacote a pacote e tolera nome que
mudou entre versões (a transição
t64do trixie renomeou várias libs) Xvfb :99+openbox+x11vnc- Usuário
painelbot, worker em/opt/painelbots-worker npm installe download do Camoufox (~150 MB)- Units systemd: display, WM, worker e VNC — todas com
Restart=always - 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
Só 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 comPasswordAuthentication nonosshd_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
-
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. -
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.
Só 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
.enve 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_idainda é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
scrypte 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-workerpor 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 |