Compare commits
5 Commits
pre-portai
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 708f6373f1 | |||
|
|
13e672c18d | ||
| f82ef9d82e | |||
| 55c4d990b2 | |||
| 3f1d2341ce |
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
84
app/app.py
84
app/app.py
@@ -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()
|
||||
|
||||
84
app/templates/_alerts.html
Normal file
84
app/templates/_alerts.html
Normal 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 %}
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user