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

89 lines
3.7 KiB
Markdown

# 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.
```bash
sudo cscli machines add crowdsec-admin --password '<PW>' -f -
```
2. **Register a bouncer** on the host (for GET). Save the key in `secrets.yml`.
```bash
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
```bash
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 `exec`s 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.