Compare commits

5 Commits

Author SHA1 Message Date
708f6373f1 feat: alerts view + created-at timestamps on decisions
All checks were successful
Deploy / deploy (push) Successful in 22s
Mirror cscli alerts list: new /alerts endpoint hits LAPI machine auth
with since/ip/scenario/origin filters, renders ID, scope:value, reason,
country, AS, events, decisions, created_at.

Decisions table gains a Created column derived from until - duration
(LAPI does not expose created_at on /v1/decisions). Both views format
timestamps locally and append a relative "x ago" via Intl.RelativeTimeFormat,
refreshed every 30s on the decisions view.
2026-06-21 15:13:14 +02:00
Alex
13e672c18d chore: test auto-redeploy from CI
All checks were successful
Deploy / deploy (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-20 22:51:32 +02:00
f82ef9d82e chore(docker): add HEALTHCHECK probe
All checks were successful
Deploy / deploy (push) Successful in 17s
Python urllib GET / on 127.0.0.1:8000. start_period=30s
gives gunicorn workers time to boot. Lets Docker mark
container unhealthy if Flask app wedges, and feeds
Portainer + diagnose.sh signal that the app is alive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-20 21:31:33 +02:00
55c4d990b2 test: verify end-to-end webhook flow
All checks were successful
Deploy / deploy (push) Successful in 17s
2026-06-20 12:18:56 +02:00
3f1d2341ce ci: migrate to Portainer Git stack + registry-pushed image
Some checks failed
Deploy / deploy (push) Failing after 24s
- Compose: build → image:latest from Gitea registry (ci namespace)
- Workflow: build + push image + POST Portainer webhook (vs. host docker compose up)
- Drop transient .env write — secrets now live in Portainer stack Env
- Add crowdsec@file middleware (defense-in-depth project rule)

Repo secrets required: REGISTRY_TOKEN, PORTAINER_WEBHOOK_URL.
Rollback branch: pre-portainer-migration.
2026-06-20 12:16:56 +02:00
7 changed files with 262 additions and 38 deletions

View File

@@ -1,21 +1,13 @@
# ──────────────────────────────────────────────────────────────────────────────
# 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.
# Build image, push to Gitea registry, trigger Portainer redeploy via webhook.
# Stack itself is managed by Portainer (type=git). Env vars (LAPI creds) live
# in the Portainer stack — NOT in repo secrets — so rotation is one place.
#
# PREREQUISITES (one-time):
# 1. Host LAPI machine registered (for DELETE auth):
# sudo cscli machines add crowdsec-admin --password '<PW>' -f -
# 2. Host LAPI bouncer registered (for GET auth):
# sudo cscli bouncers add crowdsec-admin
# 3. Repo secrets set in Gitea → Settings → Secrets:
# LAPI_MACHINE_ID=crowdsec-admin
# LAPI_MACHINE_PASSWORD=<PW>
# LAPI_BOUNCER_KEY=<bouncer key from step 2>
# 4. DNS: crowdsec.domverse-berlin.eu → host IP.
# 5. Authentik wildcard forward_domain already covers *.domverse-berlin.eu.
# Repo secrets required:
# REGISTRY_TOKEN token for ci user, scope write:package
# PORTAINER_WEBHOOK_URL POST URL from Portainer stack auto-update setting
# ──────────────────────────────────────────────────────────────────────────────
name: Deploy
@@ -27,8 +19,7 @@ on:
workflow_dispatch:
env:
COMPOSE_PROJECT: crowdsec-admin
COMPOSE_FILE: docker-compose.yml
IMAGE: git.domverse-berlin.eu/ci/crowdsec-admin/app
jobs:
deploy:
@@ -37,25 +28,18 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Write .env for compose
run: |
cat > .env <<EOF
LAPI_MACHINE_ID=${{ secrets.LAPI_MACHINE_ID }}
LAPI_MACHINE_PASSWORD=${{ secrets.LAPI_MACHINE_PASSWORD }}
LAPI_BOUNCER_KEY=${{ secrets.LAPI_BOUNCER_KEY }}
EOF
chmod 600 .env
- name: Login to Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.domverse-berlin.eu -u ci --password-stdin
- name: Deploy with docker compose
- name: Build and push
run: |
echo "=== Deploying commit ${{ gitea.sha }} to ${{ gitea.ref_name }} ==="
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" up --build -d --remove-orphans
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" ./app
docker push "$IMAGE:latest"
docker push "$IMAGE:${{ gitea.sha }}"
- name: Show health
- name: Trigger Portainer redeploy
run: |
sleep 3
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" ps
docker exec "$COMPOSE_PROJECT" python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).read().decode())" || true
curl -sk -X POST --fail "${{ secrets.PORTAINER_WEBHOOK_URL }}"
- name: Prune dangling images
run: docker image prune -f

View File

@@ -11,4 +11,7 @@ COPY templates/ templates/
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=30s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/').close()" || exit 1
CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "--access-logfile", "-", "app:app"]

View File

@@ -1,7 +1,9 @@
import os
import re
import time
import ipaddress
import logging
from datetime import datetime, timedelta, timezone
import requests
from flask import Flask, render_template, request, jsonify, abort
@@ -42,6 +44,52 @@ def _jwt():
return _token["jwt"]
_GO_DURATION_RE = re.compile(
r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?(?:(\d+(?:\.\d+)?)(?:us|µs))?(?:(\d+)ns)?$"
)
def _parse_go_duration(s):
if not s:
return None
m = _GO_DURATION_RE.match(s.strip())
if not m or not any(m.groups()):
return None
h, mn, sc, ms, us, ns = m.groups()
return timedelta(
hours=int(h or 0),
minutes=int(mn or 0),
seconds=float(sc or 0),
milliseconds=float(ms or 0),
microseconds=float(us or 0) + (float(ns or 0) / 1000.0),
)
def _parse_iso(s):
if not s:
return None
# CrowdSec emits RFC3339 w/ nanos; Python fromisoformat tolerates up to micros only.
s = s.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
# Trim fractional seconds to 6 digits.
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
try:
return datetime.fromisoformat(s)
except ValueError:
return None
def _enrich(decisions):
for d in decisions:
until = _parse_iso(d.get("until"))
dur = _parse_go_duration(d.get("duration"))
if until and dur:
created = until - dur
d["created_at"] = created.astimezone(timezone.utc).isoformat()
return decisions
def _bouncer(method, path, **kw):
headers = kw.pop("headers", {})
headers["X-Api-Key"] = BOUNCER_KEY
@@ -114,10 +162,44 @@ def list_decisions():
r = _bouncer("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=[], total=0, limit=limit), 502
decisions = r.json() or []
decisions = _enrich(r.json() or [])
return render_template("_decisions.html", decisions=decisions, error=None, total=len(decisions), limit=limit)
ALERT_SINCE_DEFAULT = "1h"
ALERT_SINCE_ALLOWED = {"15m", "1h", "6h", "24h", "168h", "720h"}
@app.get("/alerts")
def list_alerts():
since = request.args.get("since", ALERT_SINCE_DEFAULT).strip()
if since not in ALERT_SINCE_ALLOWED:
since = ALERT_SINCE_DEFAULT
ip = request.args.get("ip", "").strip()
scenario = request.args.get("scenario", "").strip()
origin = request.args.get("origin", "").strip()
try:
limit = max(1, min(MAX_LIMIT, int(request.args.get("limit", DEFAULT_LIMIT))))
except ValueError:
limit = DEFAULT_LIMIT
params = {"limit": limit, "since": since}
if ip:
if not valid_ip(ip):
return render_template("_alerts.html", error="invalid IP", alerts=[], total=0, limit=limit, since=since), 400
params["ip"] = ip
if scenario:
params["scenario"] = scenario
if origin:
params["origin"] = origin
r = _machine("GET", "/v1/alerts", params=params)
if r.status_code != 200:
return render_template("_alerts.html", error=f"LAPI {r.status_code}: {r.text[:200]}", alerts=[], total=0, limit=limit, since=since), 502
alerts = r.json() or []
return render_template("_alerts.html", alerts=alerts, error=None, total=len(alerts), limit=limit, since=since)
@app.post("/unban")
def unban():
ip = request.form.get("ip", "").strip()

View File

@@ -0,0 +1,84 @@
{% if error %}
<p class="err">{{ error }}</p>
{% endif %}
{% if alerts %}
<p style="font-size:.8rem;color:#888;margin:.4rem 0;">
Showing <strong>{{ total }}</strong> alert{{ '' if total == 1 else 's' }}
since <code>{{ since }}</code>
(server limit {{ limit }}{% if total == limit %} — at cap, raise limit if more expected{% endif %}).
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Scope:Value</th>
<th>Reason</th>
<th>Country</th>
<th>AS</th>
<th>Events</th>
<th>Decisions</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for a in alerts %}
{% set src = a.source or {} %}
<tr>
<td><code>{{ a.id }}</code></td>
<td><code>{{ src.scope or a.scope or '?' }}:{{ src.value or a.value or '?' }}</code></td>
<td>
{{ a.scenario }}
{% if a.events_count and a.events_count > 1 %}<span class="pill">×{{ a.events_count }}</span>{% endif %}
</td>
<td>{{ src.cn or '—' }}</td>
<td>{% if src.as_number %}{{ src.as_number }}{% if src.as_name %} {{ src.as_name }}{% endif %}{% else %}—{% endif %}</td>
<td>{{ a.events_count or 0 }}</td>
<td>
{% if a.decisions %}
{% for dec in a.decisions %}
<span class="pill" title="origin={{ dec.origin }} until={{ dec.until }}">{{ dec.type }} {{ dec.duration }}{% if dec.simulated %} sim{% endif %}</span>
{% endfor %}
{% else %}
{% endif %}
</td>
<td><time class="ts" datetime="{{ a.created_at }}">{{ a.created_at }}</time></td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
(function(){
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const FMT = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
const UNITS = [['year',31536000],['month',2592000],['day',86400],['hour',3600],['minute',60],['second',1]];
function relative(target, now){
const diff = (target - now) / 1000;
const abs = Math.abs(diff);
for (const [u, s] of UNITS) {
if (abs >= s || u === 'second') return RTF.format(Math.round(diff / s), u);
}
}
const now = new Date();
document.querySelectorAll('#alerts time.ts').forEach(t => {
const iso = t.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d)) return;
t.textContent = FMT.format(d);
t.title = iso + ' (' + relative(d, now) + ')';
const small = document.createElement('small');
small.style.color = '#888';
small.style.marginLeft = '.35rem';
small.textContent = relative(d, now);
t.appendChild(small);
});
})();
</script>
{% else %}
{% if not error %}<p>No alerts in window.</p>{% endif %}
{% endif %}

View File

@@ -16,7 +16,7 @@
<thead>
<tr>
<th><input type="checkbox" id="sel-all"></th>
<th>ID</th><th>IP / value</th><th>Scope</th><th>Type</th><th>Reason</th><th>Until</th><th>Origin</th><th></th>
<th>ID</th><th>IP / value</th><th>Scope</th><th>Type</th><th>Reason</th><th>Created</th><th>Until</th><th>Origin</th><th></th>
</tr>
</thead>
<tbody>
@@ -28,7 +28,8 @@
<td>{{ d.scope }}</td>
<td>{{ d.type }}</td>
<td>{{ d.scenario }}</td>
<td>{{ d.until }}</td>
<td><time class="ts" datetime="{{ d.created_at }}">{{ d.created_at or '—' }}</time></td>
<td><time class="ts" datetime="{{ d.until }}">{{ d.until }}</time></td>
<td>{{ d.origin }}</td>
<td>
<button class="danger" type="button"
@@ -42,6 +43,47 @@
</table>
</form>
<script>
(function(){
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const FMT = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
const UNITS = [
['year', 31536000],
['month', 2592000],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1],
];
function relative(target, now){
const diff = (target - now) / 1000;
const abs = Math.abs(diff);
for (const [u, s] of UNITS) {
if (abs >= s || u === 'second') return RTF.format(Math.round(diff / s), u);
}
}
function render(){
const now = new Date();
document.querySelectorAll('#decisions time.ts').forEach(t => {
const iso = t.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d)) return;
t.textContent = FMT.format(d);
t.title = iso + ' (' + relative(d, now) + ')';
const small = document.createElement('small');
small.style.color = '#888';
small.style.marginLeft = '.35rem';
small.textContent = relative(d, now);
t.appendChild(small);
});
}
render();
setInterval(render, 30000);
})();
(function(){
const form = document.getElementById('bulk-form');
const all = document.getElementById('sel-all');

View File

@@ -39,6 +39,37 @@
</div>
<div id="unban-me-result"></div>
<section>
<h2>Recent alerts</h2>
<form hx-get="/alerts" hx-target="#alerts" hx-trigger="submit, load delay:200ms" hx-swap="innerHTML" hx-indicator="#alerts" class="row">
<select name="since">
<option value="15m">15m</option>
<option value="1h" selected>1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="168h">7d</option>
<option value="720h">30d</option>
</select>
<input type="text" name="ip" placeholder="IP (optional)">
<input type="text" name="scenario" placeholder="Scenario (optional)">
<select name="origin">
<option value="">any origin</option>
<option value="crowdsec">crowdsec</option>
<option value="cscli">cscli</option>
<option value="CAPI">CAPI</option>
<option value="lists">lists</option>
</select>
<select name="limit">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<button type="submit">Search</button>
</form>
<div id="alerts">Loading…</div>
</section>
<section>
<h2>Active decisions</h2>
<form hx-get="/decisions" hx-target="#decisions" hx-trigger="submit, load delay:200ms" hx-swap="innerHTML" hx-indicator="#decisions" class="row">

View File

@@ -1,8 +1,6 @@
services:
crowdsec-admin:
build:
context: ./app
image: crowdsec-admin:local
image: git.domverse-berlin.eu/ci/crowdsec-admin/app:${TAG:-latest}
container_name: crowdsec-admin
restart: unless-stopped
networks:
@@ -21,7 +19,7 @@ services:
- "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.routers.crowdsec-admin.middlewares=crowdsec@file,authentik@docker"
- "traefik.http.services.crowdsec-admin.loadbalancer.server.port=8000"
- "kuma.crowdsec-admin.http.name=CrowdSec Admin"