Files
crowdsec-admin/README.md
domverse 5d589915f7
All checks were successful
Deploy / deploy (push) Successful in 19s
fix: split LAPI auth — bouncer key for read, machine JWT for delete
LAPI does not let machine JWTs hit GET /v1/decisions even after the machine
is validated (returns 403 access forbidden). Conversely, bouncer X-Api-Key
does not satisfy DELETE /v1/decisions (returns 401 "cookie token is empty").

The webapp now holds both credentials and routes each call to the right
authority. Adds LAPI_BOUNCER_KEY env var + Gitea secret.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 23:58:20 +02:00

3.7 KiB

crowdsec-admin

Tiny Flask + htmx webapp to list and delete CrowdSec decisions (bans) from a browser, gated behind Authentik.

Built so the admin can unban an IP without SSH when their own network routes change. Source of truth: this Gitea repo. Push to main → Gitea Actions runner rebuilds and redeploys via docker compose on the host.

Routes

  • GET / — UI
  • GET /decisions — list active decisions (htmx fragment), optional ?ip=
  • POST /unban — delete by decision id or by ip
  • POST /unban-me — delete decisions for the caller's IP (uses X-Forwarded-For)
  • GET /healthz — JSON, returns 503 if LAPI unreachable

How it talks to CrowdSec

LAPI lives on the host at 0.0.0.0:8080. Container reaches it via host.docker.internal:host-gateway.

LAPI splits read vs write auth:

Endpoint Auth Notes
GET /v1/decisions bouncer X-Api-Key Machine JWT returns 403 even when validated.
DELETE /v1/decisions[/{id}] machine Bearer JWT Bouncer key returns 401 "cookie token is empty".

So the app holds both credentials. JWT is acquired via POST /v1/watchers/login, cached ~13 min, auto-refreshed on 401. Bouncer key is used as-is.

One-time setup

  1. Register the LAPI machine on the host (for DELETE). Choose a strong password and save it in secrets.yml. -f - dumps creds to stdout instead of overwriting the local agent's credentials file.

    sudo cscli machines add crowdsec-admin --password '<PW>' -f -
    
  2. Register a bouncer on the host (for GET). Save the key in secrets.yml.

    sudo cscli bouncers add crowdsec-admin
    
  3. Gitea repo secrets (Settings → Secrets and variables → Actions):

    • LAPI_MACHINE_ID=crowdsec-admin
    • LAPI_MACHINE_PASSWORD=<PW from step 1>
    • LAPI_BOUNCER_KEY=<key from step 2>
  4. DNS: crowdsec.domverse-berlin.eu → host IP.

  5. Authentik: nothing — the existing wildcard forward_domain provider covers *.domverse-berlin.eu, and the authentik@docker label in docker-compose.yml gates the router.

  6. First deploy: push to main or trigger workflow_dispatch. Runner builds the image, brings the stack up, and reports health.

Layout

crowdsec-admin/
├── .gitea/workflows/deploy.yml   # CI/CD on push to main
├── docker-compose.yml            # Traefik labels + Kuma + authentik@docker
├── app/
│   ├── Dockerfile                # python:3.12-slim + gunicorn
│   ├── requirements.txt
│   ├── app.py                    # Flask, ~120 LOC
│   └── templates/
│       ├── index.html            # full page
│       ├── _decisions.html       # htmx fragment
│       └── _unban_me.html        # htmx fragment
└── README.md

Local dev

docker compose up --build -d
# or, on the host:
LAPI_URL=http://localhost:8080 LAPI_MACHINE_ID=... LAPI_MACHINE_PASSWORD=... \
  python -m flask --app app/app.py run --port 8000

Security notes

  • Authentik is the only auth gate. Admin compromise = unban anything.
  • App never execs shell. Only LAPI HTTP. IP inputs validated with ipaddress.ip_address. Decision-id input validated isdigit().
  • TRUSTED_PROXY_HOPS=1 picks the IP Traefik saw. Increase if a second proxy sits in front.

Pattern note

This stack is not Portainer-managed — it follows the same convention as the flight-radar repo: source in Gitea, runner brings the stack up on the host via docker compose. Other (non-CI) stacks are deployed via Portainer API per the infra rule.