LibrePortal/scripts/system/libreportal-crowdsec
librelad 7aed9102c4 fix(tools): keep the user on the Tools tab and show the result there
Running any tool jumped to the Tasks tab and left the user stranded there. That
is right for an install — long, log-heavy, worth watching — and wrong for a
tool, which is a short admin action whose answer is one line. Worse, half of
these are only meaningful back on Tools: List Users opens a modal over that tab,
and Create User Account returns a generated password that was being buried in a
log the user then had to go read.

Tools now stay put. On completion the tool's own outcome lines — the
isSuccessful/isError/isNotice output, ANSI stripped and framework boilerplate
filtered — are shown in a small result modal, with a View log button for
anything needing the full detail. list_users is left alone because the existing
account-list modal is already a better result view.

Also stops generate_arrays.sh walking scripts/dev. That directory is
`export-ignore`d, so it exists in a working clone but never in a shipped
install; generating a files_dev.sh entry from it wrote a reference into
files_source.sh that no install could satisfy, and the loader treats a missing
array file as a broken installation — every libreportal command stopped with
"files_dev.sh is missing from your LibrePortal Installation". Excluded alongside
unused/, system/ and release/. Regenerating also picked up scripts/validation,
which had never had an array file.

And Matrix's account listing prints its aligned line from python rather than
re-splitting the marker line in bash: TAB is IFS whitespace, so an empty display
name collapsed into the previous delimiter and shifted every later column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:25:19 +01:00

233 lines
11 KiB
Bash

#!/bin/bash
# LibrePortal CrowdSec helper — root-privileged ops for the host-side CrowdSec
# install (the agent + firewall bouncer the LibrePortal Traefik bouncer talks
# to). Installed root:root 0755 to /usr/local/lib/libreportal/ by init.sh.
# Self-contained: each action runs a FIXED set of ops with strictly-validated
# args, so the scoped sudoers needn't grant the manager blanket apt/cscli/tee/
# sed/chown on /etc/crowdsec or /var/log. The runtime calls in via
# `runCrowdsec <action> [args…]` (run_privileged.sh).
set -u
[[ $EUID -eq 0 ]] || { echo "libreportal-crowdsec: must run as root" >&2; exit 1; }
# Baked at install; unbaked copies keep the "__" sentinel.
SYSTEM_DIR="__SYSTEM_DIR__"
CONTAINERS_DIR="__CONTAINERS_DIR__"
MANAGER_USER="__MANAGER__"
[[ "$SYSTEM_DIR" == *"__"* || -z "$SYSTEM_DIR" ]] && SYSTEM_DIR="/libreportal-system"
[[ "$CONTAINERS_DIR" == *"__"* || -z "$CONTAINERS_DIR" ]] && CONTAINERS_DIR="/libreportal-containers"
[[ "$MANAGER_USER" == *"__"* || -z "$MANAGER_USER" ]] && MANAGER_USER="libreportal"
CFG_FILE="/etc/crowdsec/config.yaml"
BOUNCER_CFG="/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml"
TRAEFIK_KEY_FILE="/etc/crowdsec/traefik_bouncer.key"
HOST_LOGS=("/var/log/crowdsec.log" "/var/log/crowdsec-firewall-bouncer.log")
# --- install: add repo + apt install + initial config -------------------------
# Idempotent: skips repo add if /etc/apt/sources.list.d/crowdsec_*.list exists,
# skips apt install if cscli is on PATH, skips collections install if already
# present.
crowdsec_install() {
if ! command -v cscli >/dev/null 2>&1; then
if ! ls /etc/apt/sources.list.d/crowdsec_*.list >/dev/null 2>&1; then
curl -fsSL https://install.crowdsec.net | bash >/dev/null 2>&1 || {
echo "libreportal-crowdsec: failed to add CrowdSec apt repo" >&2; return 1
}
fi
DEBIAN_FRONTEND=noninteractive apt-get install -y -q crowdsec </dev/null >/dev/null 2>&1 || {
echo "libreportal-crowdsec: apt-get install crowdsec failed" >&2; return 1
}
fi
if ! dpkg -s crowdsec-firewall-bouncer-nftables >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -q crowdsec-firewall-bouncer-nftables </dev/null >/dev/null 2>&1 || {
echo "libreportal-crowdsec: apt-get install firewall-bouncer failed" >&2; return 1
}
fi
systemctl enable --now crowdsec >/dev/null 2>&1 || true
systemctl enable --now crowdsec-firewall-bouncer >/dev/null 2>&1 || true
# Baseline collections. cscli is idempotent ("already installed" exits 0).
cscli collections install crowdsecurity/linux >/dev/null 2>&1 || true
cscli collections install crowdsecurity/sshd >/dev/null 2>&1 || true
systemctl reload crowdsec >/dev/null 2>&1 || true
}
# --- services {enable|disable|restart} ----------------------------------------
crowdsec_services() {
case "${1:-}" in
enable)
systemctl enable --now crowdsec || return 1
systemctl enable --now crowdsec-firewall-bouncer || return 1
;;
disable)
systemctl disable --now crowdsec-firewall-bouncer >/dev/null 2>&1 || true
systemctl disable --now crowdsec >/dev/null 2>&1 || true
;;
restart)
systemctl restart crowdsec || return 1
;;
*) echo "libreportal-crowdsec: services {enable|disable|restart}" >&2; return 2 ;;
esac
}
# --- capi {register|unregister|status} ----------------------------------------
# Community blocklist (subscribe + send anonymous attack signals).
crowdsec_capi() {
case "${1:-}" in
register) cscli capi register 2>&1 ;;
unregister) cscli capi unregister 2>&1 ;;
status) cscli capi status 2>&1 ;;
*) echo "libreportal-crowdsec: capi {register|unregister|status}" >&2; return 2 ;;
esac
}
# --- console {enroll <token>|disenroll|status} --------------------------------
# SaaS dashboard at app.crowdsec.net. Token strictly validated.
crowdsec_console() {
case "${1:-}" in
enroll)
local token="${2:-}"
[[ "$token" =~ ^[A-Za-z0-9_-]{8,128}$ ]] || {
echo "libreportal-crowdsec: console enroll <token> — invalid token format" >&2
return 1
}
cscli console enroll "$token" 2>&1
;;
disenroll) cscli console disenroll 2>&1 ;;
status) cscli console status 2>&1 ;;
*) echo "libreportal-crowdsec: console {enroll <token>|disenroll|status}" >&2; return 2 ;;
esac
}
# --- traefik bouncer: register + write key file -------------------------------
# Shared by init and rotate: registers the bouncer with cscli and writes the key
# file. Prints GENERATED:<key> so the caller can mirror it into the config.
_crowdsec_bouncer_traefik_add() {
local key
key=$(cscli bouncers add traefik-bouncer -o raw 2>&1 | tail -1)
[[ -n "$key" && "$key" != *"error"* ]] || {
echo "libreportal-crowdsec: cscli bouncers add failed: $key" >&2
return 1
}
# Write the key file: bind-mounted RO into Traefik. Manager-owned so the
# WebUI/config layer (running as the manager) can read it; mode 0600.
printf '%s\n' "$key" > "$TRAEFIK_KEY_FILE"
chown "${MANAGER_USER}:${MANAGER_USER}" "$TRAEFIK_KEY_FILE"
chmod 0600 "$TRAEFIK_KEY_FILE"
echo "GENERATED:$key"
}
# init: idempotent. "EXISTS" when the bouncer is already registered (the caller
# recovers the key from the key file), GENERATED:<key> when newly created.
crowdsec_bouncer_traefik_init() {
if cscli bouncers list -o raw 2>/dev/null | grep -q '^traefik-bouncer'; then
echo "EXISTS"
return 0
fi
_crowdsec_bouncer_traefik_add
}
# --- bouncer-traefik-rotate: issue a new key for the Traefik bouncer ----------
# Delete then re-add: cscli cannot re-issue a key for an existing bouncer, and it
# cannot print an existing one either. The old key stops working the moment the
# bouncer is deleted, so the caller must restart Traefik to pick up the new one —
# there is a window where Traefik authenticates with a dead key.
crowdsec_bouncer_traefik_rotate() {
if cscli bouncers list -o raw 2>/dev/null | grep -q '^traefik-bouncer'; then
cscli bouncers delete traefik-bouncer >/dev/null 2>&1 || {
echo "libreportal-crowdsec: could not delete the existing traefik-bouncer" >&2
return 1
}
fi
_crowdsec_bouncer_traefik_add
}
# --- bind-lapi <addr:port>: set listen_uri in config.yaml ---------------------
# Traefik talks to LAPI via host.docker.internal. The bouncer API key gates
# external access (HTTP 401 without it).
# Takes the bind target as <addr>:<port>, validated the same way the prometheus
# action validates its own, so the scoped sudoers still only ever sees a fixed
# edit. Defaults to 0.0.0.0:8080 when the caller passes nothing.
crowdsec_bind_lapi() {
local target="${1:-0.0.0.0:8080}"
local addr="${target%%:*}" port="${target##*:}"
[[ "$addr" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "libreportal-crowdsec: bind-lapi invalid addr" >&2; return 1; }
[[ "$port" =~ ^[0-9]+$ ]] || { echo "libreportal-crowdsec: bind-lapi invalid port" >&2; return 1; }
[[ -f "$CFG_FILE" ]] || { echo "libreportal-crowdsec: $CFG_FILE not found" >&2; return 1; }
local esc_addr="${addr//./\\.}"
if grep -qE "listen_uri:[[:space:]]*${esc_addr}:${port}([[:space:]]|$)" "$CFG_FILE"; then
echo "ALREADY_BOUND"; return 0
fi
sed -i "s|listen_uri:.*|listen_uri: ${addr}:${port}|" "$CFG_FILE"
}
# --- prometheus {on <addr> <port>|off} ----------------------------------------
# Flip CrowdSec's Prometheus metrics endpoint. `on` requires an addr + port the
# Prometheus container can reach; `off` rebinds to 127.0.0.1.
crowdsec_prometheus() {
[[ -f "$CFG_FILE" ]] || { echo "libreportal-crowdsec: $CFG_FILE not found" >&2; return 1; }
case "${1:-}" in
on)
local addr="${2:-}" port="${3:-}"
[[ "$addr" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "libreportal-crowdsec: invalid addr" >&2; return 1; }
[[ "$port" =~ ^[0-9]+$ ]] || { echo "libreportal-crowdsec: invalid port" >&2; return 1; }
sed -i "/^prometheus:/,/^[^[:space:]#]/ {
s|enabled:.*|enabled: true|
s|listen_addr:.*|listen_addr: ${addr}|
s|listen_port:.*|listen_port: ${port}|
}" "$CFG_FILE"
;;
off)
sed -i "/^prometheus:/,/^[^[:space:]#]/ s|listen_addr:.*|listen_addr: 127.0.0.1|" "$CFG_FILE"
;;
*) echo "libreportal-crowdsec: prometheus {on <addr> <port>|off}" >&2; return 2 ;;
esac
}
# --- touch-host-logs: make crowdsec logs readable by the libreportal container --
crowdsec_touch_host_logs() {
local l
for l in "${HOST_LOGS[@]}"; do
touch "$l" 2>/dev/null || true
chmod 0644 "$l" 2>/dev/null || true
done
}
# --- bouncer-priority: set nftables ipv4/ipv6 priority to -100 -----------------
# Moved here from libreportal-appcfg.crowdsec_priority. Same transform; lives in
# the dedicated crowdsec helper so all CrowdSec-touching root ops are in one
# auditable file.
crowdsec_bouncer_priority() {
[[ -f "$BOUNCER_CFG" ]] || { echo "libreportal-crowdsec: $BOUNCER_CFG not found" >&2; return 1; }
cp "$BOUNCER_CFG" "${BOUNCER_CFG}.bak.$(date +%Y%m%d-%H%M%S)"
awk -v p="-100" '
BEGIN { in_v4=0; in_v6=0 }
/^[[:space:]]*ipv4:/ { in_v4=1; in_v6=0; print; next }
/^[[:space:]]*ipv6:/ { in_v6=1; in_v4=0; print; next }
/^[a-zA-Z]/ { in_v4=0; in_v6=0 }
in_v4 && /^[[:space:]]+priority:/ { sub(/priority:.*/, "priority: " p) }
in_v6 && /^[[:space:]]+priority:/ { sub(/priority:.*/, "priority: " p) }
{ print }
' "$BOUNCER_CFG" > "${BOUNCER_CFG}.new" && mv "${BOUNCER_CFG}.new" "$BOUNCER_CFG"
}
action="${1:-}"; shift 2>/dev/null || true
case "$action" in
install) crowdsec_install ;;
services) crowdsec_services "${1:-}" ;;
capi) crowdsec_capi "${1:-}" ;;
console) crowdsec_console "${1:-}" "${2:-}" ;;
bouncer-traefik-init) crowdsec_bouncer_traefik_init ;;
bouncer-traefik-rotate) crowdsec_bouncer_traefik_rotate ;;
bouncer-priority) crowdsec_bouncer_priority ;;
bind-lapi) crowdsec_bind_lapi "${1:-}" ;;
prometheus) crowdsec_prometheus "${1:-}" "${2:-}" "${3:-}" ;;
touch-host-logs) crowdsec_touch_host_logs ;;
*)
echo "usage: libreportal-crowdsec {install|services <enable|disable|restart>|capi <register|unregister|status>|console <enroll <token>|disenroll|status>|bouncer-traefik-init|bouncer-traefik-rotate|bouncer-priority|bind-lapi <addr:port>|prometheus <on <addr> <port>|off>|touch-host-logs}" >&2
exit 2
;;
esac