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 # CrowdSec Admin — Gitea Actions CI/CD
# #
# Triggers on push to main. Runner builds image + brings stack up via docker # Build image, push to Gitea registry, trigger Portainer redeploy via webhook.
# compose on the host. Image stays local (no registry push), same pattern as # Stack itself is managed by Portainer (type=git). Env vars (LAPI creds) live
# flight-radar. # in the Portainer stack — NOT in repo secrets — so rotation is one place.
# #
# PREREQUISITES (one-time): # Repo secrets required:
# 1. Host LAPI machine registered (for DELETE auth): # REGISTRY_TOKEN token for ci user, scope write:package
# sudo cscli machines add crowdsec-admin --password '<PW>' -f - # PORTAINER_WEBHOOK_URL POST URL from Portainer stack auto-update setting
# 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.
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
name: Deploy name: Deploy
@@ -27,8 +19,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
COMPOSE_PROJECT: crowdsec-admin IMAGE: git.domverse-berlin.eu/ci/crowdsec-admin/app
COMPOSE_FILE: docker-compose.yml
jobs: jobs:
deploy: deploy:
@@ -37,25 +28,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Write .env for compose - name: Login to Gitea registry
run: | run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.domverse-berlin.eu -u ci --password-stdin
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: Deploy with docker compose - name: Build and push
run: | run: |
echo "=== Deploying commit ${{ gitea.sha }} to ${{ gitea.ref_name }} ===" docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" ./app
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" up --build -d --remove-orphans docker push "$IMAGE:latest"
docker push "$IMAGE:${{ gitea.sha }}"
- name: Show health - name: Trigger Portainer redeploy
run: | run: |
sleep 3 curl -sk -X POST --fail "${{ secrets.PORTAINER_WEBHOOK_URL }}"
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
- name: Prune dangling images - name: Prune dangling images
run: docker image prune -f run: docker image prune -f

View File

@@ -11,4 +11,7 @@ COPY templates/ templates/
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
EXPOSE 8000 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"] CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "--access-logfile", "-", "app:app"]

View File

@@ -1,7 +1,9 @@
import os import os
import re
import time import time
import ipaddress import ipaddress
import logging import logging
from datetime import datetime, timedelta, timezone
import requests import requests
from flask import Flask, render_template, request, jsonify, abort from flask import Flask, render_template, request, jsonify, abort
@@ -42,6 +44,52 @@ def _jwt():
return _token["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): def _bouncer(method, path, **kw):
headers = kw.pop("headers", {}) headers = kw.pop("headers", {})
headers["X-Api-Key"] = BOUNCER_KEY headers["X-Api-Key"] = BOUNCER_KEY
@@ -114,10 +162,44 @@ def list_decisions():
r = _bouncer("GET", "/v1/decisions", params=params) r = _bouncer("GET", "/v1/decisions", params=params)
if r.status_code != 200: 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 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) 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") @app.post("/unban")
def unban(): def unban():
ip = request.form.get("ip", "").strip() 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> <thead>
<tr> <tr>
<th><input type="checkbox" id="sel-all"></th> <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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -28,7 +28,8 @@
<td>{{ d.scope }}</td> <td>{{ d.scope }}</td>
<td>{{ d.type }}</td> <td>{{ d.type }}</td>
<td>{{ d.scenario }}</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>{{ d.origin }}</td>
<td> <td>
<button class="danger" type="button" <button class="danger" type="button"
@@ -42,6 +43,47 @@
</table> </table>
</form> </form>
<script> <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(){ (function(){
const form = document.getElementById('bulk-form'); const form = document.getElementById('bulk-form');
const all = document.getElementById('sel-all'); const all = document.getElementById('sel-all');

View File

@@ -39,6 +39,37 @@
</div> </div>
<div id="unban-me-result"></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> <section>
<h2>Active decisions</h2> <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"> <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: services:
crowdsec-admin: crowdsec-admin:
build: image: git.domverse-berlin.eu/ci/crowdsec-admin/app:${TAG:-latest}
context: ./app
image: crowdsec-admin:local
container_name: crowdsec-admin container_name: crowdsec-admin
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -21,7 +19,7 @@ services:
- "traefik.http.routers.crowdsec-admin.rule=Host(`crowdsec.domverse-berlin.eu`)" - "traefik.http.routers.crowdsec-admin.rule=Host(`crowdsec.domverse-berlin.eu`)"
- "traefik.http.routers.crowdsec-admin.entrypoints=https" - "traefik.http.routers.crowdsec-admin.entrypoints=https"
- "traefik.http.routers.crowdsec-admin.tls.certresolver=http" - "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" - "traefik.http.services.crowdsec-admin.loadbalancer.server.port=8000"
- "kuma.crowdsec-admin.http.name=CrowdSec Admin" - "kuma.crowdsec-admin.http.name=CrowdSec Admin"