1

Pitch: Criei um dashboard local para o CrowdSec + NGINX

Faz um tempo que administro meu próprio servidor em casa. A segurança dele já era funcional — CrowdSec bloqueando ataque, nginx logando tudo — mas era cega. Para entender o que estava acontecendo eu vivia no terminal, cruzando cscli decisions list com tail -f do access.log na mão.

Como o dashboard local do CrowdSec (cscli dashboard, baseado em Metabase) foi deprecado na 1.6 e removido na 1.7. A recomendação oficial virou o CrowdSec Console, hospedado na nuvem. Quem faz questão de manter tudo self-hosted ficou sem alternativa oficial.

Fonte: https://docs.crowdsec.net/blog/cscli_dashboard_deprecation

Então construí a minha. É o Mini SOC.

Painel principal do Mini SOC — cards de resumo e gráfico de bloqueios

Para que serve

Ele junta, numa interface só, as duas coisas que antes viviam separadas: os bloqueios do CrowdSec (IDS/IPS) e os logs de acesso do nginx. Tudo rodando na própria máquina do servidor — nada sai para a nuvem.

Por que não um Grafana?

Foi a primeira pergunta que me fizeram, e é o ponto central do projeto.

Grafana, Metabase e afins são read-only. Desenham gráfico lindo em cima do banco, mas param aí. Se vejo um IP martelando o servidor, o dashboard não me deixa reagir — tenho que voltar para o terminal.

Eu não queria olhar, queria operar. No Mini SOC dá para banir, desbanir, ajustar a duração de um ban, tornar permanente ou jogar um IP na whitelist com um clique, direto do painel.

E, para não virar gambiarra: não tem banco paralelo. A leitura sai direto do SQLite do CrowdSec em modo somente-leitura (PRAGMA query_only = ON); toda escrita vai por subprocess chamando o próprio cscli (lista de argumentos, nunca shell=True). O CrowdSec continua sendo a fonte da verdade — o painel é só uma cara nova em cima dele.

Tabela de decisões com o menu de ações — banir, desbanir, alterar duração

O que tem dentro

CrowdSec (IDS/IPS) — cards de resumo (bloqueados agora, IPs únicos, novos hoje, últimas 24h), série temporal e rankings por país / cenário / ASN. Filtros que aplicam sozinhos, sem botão "Aplicar". Geolocalização de IP sob demanda, com cache.

Rankings — top países, cenários e ASN

Logs do nginx — leitor dos *access.log* (inclui .gz rotacionados), com filtros, busca e estatísticas em gráfico. Tem tail ao vivo via SSE: dá para acompanhar o acesso em tempo real sem F5. Modal de detalhe por request e badge marcando IP banido.

Aba de Logs do nginx com o tail ao vivo (SSE)

Alertas — um poller avisa na hora sobre banimentos novos via Telegram, e-mail ou webhook. Não preciso ficar com o painel aberto.

Alerta de novo banimento chegando no Telegram

Onde o bloqueio realmente acontece (duas camadas)

O painel decide banir, mas quem executa o bloqueio são os bouncers do CrowdSec. E aqui tem um detalhe que me pegou de surpresa e talvez pegue você: um bouncer só não cobre tudo. Uso duas camadas, porque elas bloqueiam em pontos diferentes.

Camada 3/4 — cs-firewall-bouncer (bloqueio direto no firewall). Ele dropa os pacotes pelo IP de origem, via nftables/iptables. Cobre muito bem quem bate direto no IP do servidor — o scanner que varre a internet por IP nu. O problema: se o tráfego passa por um CDN (no meu caso, Cloudflare), o pacote chega com o IP da borda do CDN, não o do atacante. Ou seja: o IP banido passa pelo firewall, porque o firewall nunca vê o IP real.

Camada 7 — crowdsec-nginx-bouncer (bloqueio no HTTPS/443). Esse roda dentro do próprio nginx (Lua) e checa o IP real do visitante (o CF-Connecting-IP, resolvido pelo real_ip) contra as decisões do CrowdSec. Se estiver banido, devolve 403 na cara — na porta 443, no request HTTP, independente de o pacote ter vindo do CDN. É o que fecha o buraco que o firewall-bouncer sozinho deixa.

Os dois convivem: o firewall cobre acesso direto e tráfego não-HTTP; o nginx-bouncer cobre o HTTPS que vem via CDN. E, já que a página de 403 padrão entrega que é CrowdSec, troquei por uma tela de bloqueio própria — sem dar pista da stack pra quem apanhou:

Tela de bloqueio 403 customizada servida pelo nginx-bouncer na camada 7

Um aprendizado prático que deixo registrado: nos meus testes (nginx 1.26 + bouncer 1.1.6), o modo live do nginx-bouncer não bloqueava de forma confiável — só firmou com MODE=stream. Se o seu 403 não estiver disparando, é o primeiro lugar pra olhar.


Como instalar

Se você já tem CrowdSec + nginx num Debian com Python 3.11+, subir o painel é clonar, criar o venv, ajustar um .env e apontar para os seus caminhos:

git clone https://github.com/MateusBrandeburski/Mini-SOC.git
cd Mini-SOC

python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
python -c "import secrets; print('APP_SECRET=' + secrets.token_urlsafe(48))"  # cole no .env
python auth.py 'sua-senha-forte'                                              # gera o hash bcrypt do admin
$EDITOR .env   # ajuste APP_SECRET, ADMIN_PASSWORD_HASH e os caminhos do CrowdSec/nginx

python app.py  # sobe em http://0.0.0.0:8100

Em produção você joga isso numa unit systemd (After=crowdsec.service) — tem um exemplo pronto em deploy/painel-crowdsec.service. O .env.example documenta todas as variáveis; o mínimo é APP_SECRET, a senha do admin e os caminhos do banco do CrowdSec e dos logs do nginx.

Em poucos minutos o painel está no ar lendo os seus dados. Sem migração, sem banco extra, sem nuvem.

Tela de login do Mini SOC

Como eu acesso: reverse proxy no nginx

A app sobe em 127.0.0.1:8100, mas eu não acesso ela por IP:porta. Já que o servidor já roda nginx, faço o painel entrar no mesmo esquema de todo o resto: um server de reverse proxy que atende por um nome interno (ex.: painel.interno.exemplo) e repassa para a app. Assim tenho tudo gerenciado num lugar só — mesma stack, mesmo TLS, mesma cara dos outros serviços internos.

Só que o painel tem duas particularidades que o proxy precisa respeitar:

1. Ele nunca deve ficar exposto na internet. Como ele opera o CrowdSec (bane e desbane IP), é a última coisa que você quer aberta para fora. No meu server a primeira coisa é uma allowlist de IP — só a rede interna / VPN passa, o resto leva deny all. O login da própria app continua valendo; a allowlist é uma camada antes dele.

server {
    listen 80;
    server_name painel.interno.exemplo;

    # Só a rede interna / VPN entra. O resto nem chega no login.
    allow 10.0.0.0/8;
    allow 172.16.0.0/12;
    allow 192.168.0.0/16;
    deny all;

    location / {
        proxy_pass http://127.0.0.1:8100;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # SSE (tail ao vivo dos logs): sem buffering, conexão viva.
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_set_header Connection "";
    }
}

2. O tail ao vivo é SSE, e SSE morre atrás de um proxy com buffering ligado. Por isso o proxy_buffering off e o proxy_read_timeout alto — sem isso, o "acompanhar em tempo real" simplesmente não chega ao navegador. Esse detalhe me custou um tempinho de debug, então já deixo o exemplo pronto no repo.

O arquivo completo (com a variante HTTPS via certbot e a camada opcional de basic-auth do nginx) está em deploy/nginx-reverse-proxy.conf.example.

[IMG: opcional — o painel aberto no navegador já pela URL interna (https://painel.interno.exemplo), mostrando que ele vive junto dos outros serviços, não num IP:8100 solto.]


Código aberto (MIT): https://github.com/MateusBrandeburski/Mini-SOC

Feedback e PR bem-vindos — principalmente de quem também roda CrowdSec self-hosted e sentiu essa mesma falta.

Carregando publicação patrocinada...