diff --git a/scripts/fail2ban-action-shared-bans.conf b/scripts/fail2ban-action-shared-bans.conf new file mode 100644 index 0000000..688d26e --- /dev/null +++ b/scripts/fail2ban-action-shared-bans.conf @@ -0,0 +1,11 @@ +[Definition] + +# Path to staging directory +stagingdir = /var/lib/shared-bans-staging + +# Path to the actual ban-staging script (installed separately) +banscript = /usr/local/bin/shared-bans-stage-ban.sh + +actionban = "" "" "" "" + +actionunban = diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..d7984a8 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Install shared-bans client on a server. +# Run as root on the target server. +set -euo pipefail + +REPO_URL="${1:-}" +if [ -z "$REPO_URL" ]; then + echo "Usage: $0 " + echo "Example: $0 'http://shared-bans-reader:TOKEN@100.81.59.99:3030/sami7777/shared-bans.git'" + exit 1 +fi + +# Install scripts +install -m 0755 scripts/shared-bans-apply.sh /usr/local/bin/shared-bans-apply.sh +install -m 0755 scripts/shared-bans-update-static.sh /usr/local/bin/shared-bans-update-static.sh +install -m 0755 scripts/shared-bans-stage-ban.sh /usr/local/bin/shared-bans-stage-ban.sh +install -m 0644 scripts/fail2ban-action-shared-bans.conf /etc/fail2ban/action.d/shared-bans.conf + +# Config +cat > /etc/shared-bans.conf < /etc/systemd/system/shared-bans-apply.service <<'EOF' +[Unit] +Description=Apply shared IP ban list from git repo to local ipset + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/shared-bans-apply.sh +EOF + +cat > /etc/systemd/system/shared-bans-apply.timer <<'EOF' +[Unit] +Description=Apply shared IP ban list from git repo to local ipset + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target +EOF + +cat > /etc/systemd/system/shared-bans-update-static.service <<'EOF' +[Unit] +Description=Update shared IP ban list static sources from URLs + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/shared-bans-update-static.sh +EOF + +cat > /etc/systemd/system/shared-bans-update-static.timer <<'EOF' +[Unit] +Description=Nightly update of shared IP ban list static sources + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true + +[Install] +WantedBy=timers.target +EOF + +systemctl daemon-reload +systemctl enable --now shared-bans-apply.timer shared-bans-update-static.timer + +# fail2ban jail update +mkdir -p /var/lib/shared-bans-staging + +if command -v nft >/dev/null 2>&1 && systemctl is-active nftables >/dev/null 2>&1; then + FW_ACTION='nftables-multiport[name=sshd, port="22", protocol=tcp]' +else + FW_ACTION='iptables-multiport[name=sshd, port="22", protocol=tcp]' +fi + +cat > /etc/fail2ban/jail.local <>"$LOG"; then + log "ERROR: clone failed" + exit 1 + fi +else + cd "$REPO_DIR" || exit 1 + if ! git pull --ff-only 2>>"$LOG"; then + log "WARN: pull failed (using cached repo)" + fi +fi + +cd "$REPO_DIR" || exit 1 + +# Build merged list (static + dynamic, deduped, sorted) +MERGED="$REPO_DIR/merged/bans.txt" +mkdir -p "$(dirname "$MERGED")" +{ + cat "$REPO_DIR/static/bans.txt" 2>/dev/null + echo + cat "$REPO_DIR/dynamic/bans.txt" 2>/dev/null +} | grep -vE '^\s*(#|$)' | grep -E '^[0-9]' | awk '!seen[$0]++' | sort -V > "$MERGED" + +COUNT=$(wc -l < "$MERGED") +log "Merged list: $COUNT unique entries" + +if [ "$COUNT" -lt 50 ]; then + log "ERROR: suspiciously few entries ($COUNT), refusing to apply" + exit 1 +fi + +# Create main set if missing +if ! /sbin/ipset list "$IPSET_NAME" >/dev/null 2>&1; then + /sbin/ipset create "$IPSET_NAME" hash:net family inet hashsize 16384 maxelem 500000 2>>"$LOG" + log "Created ipset $IPSET_NAME" +fi + +# Build new set with unique name (PID-based) +TMP_SET="${IPSET_NAME}_new_$$" +/sbin/ipset destroy "$TMP_SET" 2>/dev/null +/sbin/ipset create "$TMP_SET" hash:net family inet hashsize 16384 maxelem 500000 2>>"$LOG" + +# Build ipset restore-format file pointing at TMP_SET (NOT the live one) +TMP_RESTORE="$(mktemp)" +sed "s|^|add ${TMP_SET} |" "$MERGED" > "$TMP_RESTORE" + +# Restore into temp set +if ! /sbin/ipset restore -file "$TMP_RESTORE" 2>>"$LOG"; then + log "ERROR: ipset restore failed (stderr above)" + /sbin/ipset destroy "$TMP_SET" 2>/dev/null + rm -f "$TMP_RESTORE" + exit 1 +fi + +PRE_SWAP=$(/sbin/ipset list "$TMP_SET" 2>/dev/null | grep -cE '^[0-9]') +log "Restored $PRE_SWAP entries into $TMP_SET" + +# Atomic swap so packets are never matched against an empty set +/sbin/ipset swap "$TMP_SET" "$IPSET_NAME" 2>>"$LOG" || { + log "ERROR: ipset swap failed" + /sbin/ipset destroy "$TMP_SET" 2>/dev/null + rm -f "$TMP_RESTORE" + exit 1 +} +/sbin/ipset destroy "$TMP_SET" 2>/dev/null +rm -f "$TMP_RESTORE" + +# Ensure iptables rule +if ! /sbin/iptables -C INPUT -m set --match-set "$IPSET_NAME" src -j DROP 2>/dev/null; then + /sbin/iptables -I INPUT 1 -m set --match-set "$IPSET_NAME" src -j DROP 2>>"$LOG" && \ + log "Inserted iptables rule for $IPSET_NAME" || \ + log "WARN: failed to insert iptables rule" +fi + +# Log rotation +if [ "$(wc -l < "$LOG")" -gt 5000 ]; then + tail -n 3000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" +fi + +APPLIED=$(/sbin/ipset list "$IPSET_NAME" 2>/dev/null | grep -cE '^[0-9]') +log "Applied: $APPLIED entries in $IPSET_NAME" +exit 0 diff --git a/scripts/shared-bans-stage-ban.sh b/scripts/shared-bans-stage-ban.sh new file mode 100644 index 0000000..2b6b469 --- /dev/null +++ b/scripts/shared-bans-stage-ban.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Stage a fail2ban ban for later promotion to the shared-bans repo. +# Args: +set -uo pipefail + +IP="$1" +JAIL="$2" +MATCH="$3" +STAGING="$4" + +mkdir -p "$STAGING" +ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) +{ + echo "# fail2ban ban at $ts jail=$JAIL match=$MATCH" + echo "$IP" +} >> "$STAGING/bans.txt" +chmod 0644 "$STAGING/bans.txt" diff --git a/scripts/shared-bans-update-static.sh b/scripts/shared-bans-update-static.sh new file mode 100644 index 0000000..53a5d36 --- /dev/null +++ b/scripts/shared-bans-update-static.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Shared ban list updater — pulls URLs from sources.json and refreshes static/bans.txt. +# Runs nightly via systemd timer on the master (DNS3). +set -uo pipefail + +REPO_DIR="/var/lib/shared-bans" +SOURCES_JSON="$REPO_DIR/sources.json" +STATIC_OUT="$REPO_DIR/static/bans.txt" +LOG="/var/log/shared-bans-update.log" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$LOG"; } + +if [ ! -d "$REPO_DIR/.git" ]; then + log "ERROR: $REPO_DIR not a git repo, run shared-bans-apply.sh first" + exit 1 +fi + +cd "$REPO_DIR" || exit 1 + +# Refresh git index (in case dynamic bans were pushed from elsewhere) +git pull --ff-only >>"$LOG" 2>&1 || log "WARN: git pull failed, using local" + +# Parse sources.json with python (jq may not be installed everywhere) +python3 << PYEOF > "$WORK/sources.txt" +import json, sys +try: + with open("$SOURCES_JSON") as f: + data = json.load(f) + for src in data.get("sources", []): + print(f"# source: {src['name']} ({src.get('comment','')})") + print(f"# url: {src['url']}") + print(f"URL\t{src['url']}") +except Exception as e: + print(f"# ERROR parsing sources.json: {e}", file=sys.stderr) + sys.exit(1) +PYEOF + +if [ ! -s "$WORK/sources.txt" ]; then + log "ERROR: sources.txt empty (python parse failed?)" + exit 1 +fi + +# Fetch each URL, dedupe +> "$WORK/all.txt" + +while IFS=$'\t' read -r tag url; do + [ "$tag" != "URL" ] && continue + [ -z "$url" ] && continue + echo "# fetching $url ..." >> "$WORK/all.txt" + if curl -fsS --max-time 60 "$url" 2>>"$LOG" >> "$WORK/all.txt"; then + log " fetched $url" + else + log " WARN: failed to fetch $url" + fi +done < "$WORK/sources.txt" + +# Clean: strip comments/blank/whitespace, keep only IP/CIDR lines +grep -E '^[0-9]' "$WORK/all.txt" | tr -d ' \t' | sort -u > "$WORK/clean.txt" + +COUNT=$(wc -l < "$WORK/clean.txt") +log "After dedupe: $COUNT unique CIDRs/IPs" + +if [ "$COUNT" -lt 50 ]; then + log "ERROR: suspiciously few entries ($COUNT), refusing to commit" + exit 1 +fi + +# Write new static file +{ + echo "# Auto-generated from sources.json — do not edit by hand." + echo "# To add new block sources, edit sources.json instead." + echo "# Last regenerated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "# Total unique entries: $COUNT" + echo + cat "$WORK/clean.txt" +} > "$STATIC_OUT.new" + +# Diff against current +if diff -q "$STATIC_OUT" "$STATIC_OUT.new" >/dev/null 2>&1; then + log "No changes since last update" + rm -f "$STATIC_OUT.new" + exit 0 +fi + +mv "$STATIC_OUT.new" "$STATIC_OUT" +log "Wrote $STATIC_OUT" + +# Commit + push +git add "$STATIC_OUT" +if git diff --cached --quiet; then + log "Nothing to commit" + exit 0 +fi + +git commit -m "Update static ban list ($(date -u +%Y-%m-%d)) [auto]" >>"$LOG" 2>&1 || { + log "ERROR: git commit failed" + exit 1 +} + +if git push origin main >>"$LOG" 2>&1; then + log "Pushed to origin" +else + log "ERROR: git push failed" + exit 1 +fi + +# Log rotation +if [ "$(wc -l < "$LOG")" -gt 5000 ]; then + tail -n 3000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" +fi + +exit 0 \ No newline at end of file