From 9c8b4ca0cc99e2a7f05027ed425f6c6b8999c32f Mon Sep 17 00:00:00 2001 From: domverse Date: Tue, 16 Jun 2026 23:39:20 +0200 Subject: [PATCH] feat: initial scaffold of CrowdSec admin webapp Flask + htmx mini-app to list and delete CrowdSec decisions from a browser, gated behind Authentik. Talks to host LAPI via host.docker.internal:8080 using machine JWT auth (bouncer X-Api-Key is read-only). Gitea Actions CI/CD on push to main: runner rebuilds image and brings the stack up via docker compose on the host (same pattern as flight-radar). Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/deploy.yml | 57 +++++++++++++++ .gitignore | 7 ++ README.md | 74 +++++++++++++++++++ app/Dockerfile | 14 ++++ app/app.py | 134 ++++++++++++++++++++++++++++++++++ app/requirements.txt | 3 + app/templates/_decisions.html | 31 ++++++++ app/templates/_unban_me.html | 1 + app/templates/index.html | 55 ++++++++++++++ docker-compose.yml | 40 ++++++++++ 10 files changed, 416 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/Dockerfile create mode 100644 app/app.py create mode 100644 app/requirements.txt create mode 100644 app/templates/_decisions.html create mode 100644 app/templates/_unban_me.html create mode 100644 app/templates/index.html create mode 100644 docker-compose.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..2d37e9d --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,57 @@ +# ────────────────────────────────────────────────────────────────────────────── +# CrowdSec Admin — Gitea Actions CI/CD +# +# Triggers on push to main. Runner builds image + brings stack up via docker +# compose on the host. Image stays local (no registry push), same pattern as +# flight-radar. +# +# PREREQUISITES (one-time): +# 1. Host LAPI machine registered: +# sudo cscli machines add crowdsec-admin --password '' +# 2. Repo secrets set in Gitea → Settings → Secrets: +# LAPI_MACHINE_ID=crowdsec-admin +# LAPI_MACHINE_PASSWORD= +# 3. DNS: crowdsec.domverse-berlin.eu → host IP. +# 4. Authentik wildcard forward_domain already covers *.domverse-berlin.eu. +# ────────────────────────────────────────────────────────────────────────────── + +name: Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +env: + COMPOSE_PROJECT: crowdsec-admin + COMPOSE_FILE: docker-compose.yml + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Write .env for compose + run: | + cat > .env <' + ``` + +2. **Gitea repo secrets** (Settings → Secrets and variables → Actions): + + - `LAPI_MACHINE_ID=crowdsec-admin` + - `LAPI_MACHINE_PASSWORD=` + +3. **DNS**: `crowdsec.domverse-berlin.eu` → host IP. + +4. **Authentik**: nothing — the existing wildcard `forward_domain` provider covers `*.domverse-berlin.eu`, and the `authentik@docker` label in `docker-compose.yml` gates the router. + +5. **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. diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..be8f4e5 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates/ templates/ + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8000 + +CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "--access-logfile", "-", "app:app"] diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..dd69214 --- /dev/null +++ b/app/app.py @@ -0,0 +1,134 @@ +import os +import time +import ipaddress +import logging +from functools import wraps + +import requests +from flask import Flask, render_template, request, jsonify, abort + +LAPI_URL = os.environ["LAPI_URL"].rstrip("/") +MACHINE_ID = os.environ["LAPI_MACHINE_ID"] +MACHINE_PW = os.environ["LAPI_MACHINE_PASSWORD"] +TRUSTED_HOPS = int(os.environ.get("TRUSTED_PROXY_HOPS", "1")) +REQ_TIMEOUT = 5 + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +log = app.logger + +_token = {"jwt": None, "exp": 0.0} + + +def _login(): + r = requests.post( + f"{LAPI_URL}/v1/watchers/login", + json={"machine_id": MACHINE_ID, "password": MACHINE_PW}, + timeout=REQ_TIMEOUT, + ) + r.raise_for_status() + data = r.json() + _token["jwt"] = data["code"] if "code" in data and isinstance(data["code"], str) else data.get("token") + if not _token["jwt"]: + _token["jwt"] = data.get("code") or data.get("token") + _token["exp"] = time.time() + 60 * 13 + return _token["jwt"] + + +def _jwt(): + if not _token["jwt"] or time.time() >= _token["exp"]: + return _login() + return _token["jwt"] + + +def _lapi(method, path, **kw): + headers = kw.pop("headers", {}) + headers["Authorization"] = f"Bearer {_jwt()}" + r = requests.request(method, f"{LAPI_URL}{path}", headers=headers, timeout=REQ_TIMEOUT, **kw) + if r.status_code == 401: + _token["exp"] = 0 + headers["Authorization"] = f"Bearer {_jwt()}" + r = requests.request(method, f"{LAPI_URL}{path}", headers=headers, timeout=REQ_TIMEOUT, **kw) + return r + + +def caller_ip(): + xff = request.headers.get("X-Forwarded-For", "") + chain = [p.strip() for p in xff.split(",") if p.strip()] + if chain and TRUSTED_HOPS > 0: + idx = max(0, len(chain) - TRUSTED_HOPS) + candidate = chain[idx] + else: + candidate = request.remote_addr + try: + ipaddress.ip_address(candidate) + except (ValueError, TypeError): + abort(400, "could not determine caller ip") + return candidate + + +def valid_ip(s): + try: + ipaddress.ip_address(s) + return True + except (ValueError, TypeError): + return False + + +@app.get("/") +def index(): + return render_template("index.html", my_ip=caller_ip()) + + +@app.get("/decisions") +def list_decisions(): + q = request.args.get("ip", "").strip() + params = {} + if q: + if not valid_ip(q): + return render_template("_decisions.html", error="invalid IP", decisions=[]), 400 + params["ip"] = q + r = _lapi("GET", "/v1/decisions", params=params) + if r.status_code != 200: + return render_template("_decisions.html", error=f"LAPI {r.status_code}: {r.text[:200]}", decisions=[]), 502 + decisions = r.json() or [] + return render_template("_decisions.html", decisions=decisions, error=None) + + +@app.post("/unban") +def unban(): + ip = request.form.get("ip", "").strip() + decision_id = request.form.get("id", "").strip() + if decision_id: + if not decision_id.isdigit(): + return "invalid id", 400 + r = _lapi("DELETE", f"/v1/decisions/{decision_id}") + elif ip: + if not valid_ip(ip): + return "invalid IP", 400 + r = _lapi("DELETE", "/v1/decisions", params={"ip": ip}) + else: + return "need id or ip", 400 + if r.status_code not in (200, 204): + return f"LAPI {r.status_code}: {r.text[:200]}", 502 + log.info("unbanned by=%s ip=%s id=%s", caller_ip(), ip, decision_id) + return list_decisions() + + +@app.post("/unban-me") +def unban_me(): + ip = caller_ip() + r = _lapi("DELETE", "/v1/decisions", params={"ip": ip}) + if r.status_code not in (200, 204): + return f"LAPI {r.status_code}: {r.text[:200]}", 502 + log.info("unban-me by=%s", ip) + return render_template("_unban_me.html", ip=ip, result=r.json() if r.text else {}) + + +@app.get("/healthz") +def healthz(): + try: + r = _lapi("GET", "/v1/decisions", params={"limit": 1}) + return jsonify(ok=r.status_code == 200, lapi_status=r.status_code), 200 if r.status_code == 200 else 503 + except Exception as e: + return jsonify(ok=False, error=str(e)), 503 diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..974fd75 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.3 +requests==2.32.3 +gunicorn==22.0.0 diff --git a/app/templates/_decisions.html b/app/templates/_decisions.html new file mode 100644 index 0000000..6c03385 --- /dev/null +++ b/app/templates/_decisions.html @@ -0,0 +1,31 @@ +{% if error %} +

{{ error }}

+{% endif %} +{% if decisions %} + + + + + + {% for d in decisions %} + + + + + + + + + + + {% endfor %} + +
IDIP / valueScopeTypeReasonUntilOrigin
{{ d.id }}{{ d.value }}{{ d.scope }}{{ d.type }}{{ d.scenario }}{{ d.until }}{{ d.origin }} +
+ + +
+
+{% else %} + {% if not error %}

No active decisions.

{% endif %} +{% endif %} diff --git a/app/templates/_unban_me.html b/app/templates/_unban_me.html new file mode 100644 index 0000000..3c089ce --- /dev/null +++ b/app/templates/_unban_me.html @@ -0,0 +1 @@ +

Requested unban for {{ ip }}. LAPI: {{ result }}

diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..966aa1f --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,55 @@ + + + + + CrowdSec Admin + + + + +

CrowdSec Admin

+
+ Your IP: {{ my_ip }} +
+ +
+
+
+ +
+

Active decisions

+
+ + + +
+
Loading…
+
+ +
+

Unban by IP

+
+ + +
+
+ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..896ed11 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + crowdsec-admin: + build: + context: ./app + image: crowdsec-admin:local + container_name: crowdsec-admin + restart: unless-stopped + networks: + - domverse + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - TZ=Europe/Berlin + - LAPI_URL=http://host.docker.internal:8080 + - LAPI_MACHINE_ID=${LAPI_MACHINE_ID} + - LAPI_MACHINE_PASSWORD=${LAPI_MACHINE_PASSWORD} + - TRUSTED_PROXY_HOPS=1 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status==200 else 1)"] + interval: 60s + timeout: 5s + retries: 3 + labels: + - "traefik.enable=true" + - "traefik.http.routers.crowdsec-admin.rule=Host(`crowdsec.domverse-berlin.eu`)" + - "traefik.http.routers.crowdsec-admin.entrypoints=https" + - "traefik.http.routers.crowdsec-admin.tls.certresolver=http" + - "traefik.http.routers.crowdsec-admin.middlewares=authentik@docker" + - "traefik.http.services.crowdsec-admin.loadbalancer.server.port=8000" + + - "kuma.crowdsec-admin.http.name=CrowdSec Admin" + - "kuma.crowdsec-admin.http.url=https://crowdsec.domverse-berlin.eu" + - "kuma.crowdsec-admin.http.interval=120" + - "kuma.crowdsec-admin.http.max_retries=2" + - "kuma.crowdsec-admin.http.retry_interval=60" + - "kuma.crowdsec-admin.http.accepted_statuscodes=[\"200-399\"]" + +networks: + domverse: + external: true