LibrePortal/containers/crowdsec/scripts/crowdsec_install_host.sh
librelad 2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

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

308 lines
15 KiB
Bash

#!/bin/bash
# Toggle the crowdsec-host-logs marker block in libreportal's live compose
# ("on" uncomments, "off" re-comments) and recreate the container so the
# mount set takes effect.
crowdsecToggleLibrePortalLogMounts() {
local mode="$1"
local compose="$(webuiDir)/docker-compose.yml"
[[ -f "$compose" ]] || return 0
case "$mode" in
on)
# Lines inside the marker block that look like `#-/var/log/crowdsec...:`
# become `-/var/log/...:` so docker compose picks up the bind-mounts.
runFileOp sed -i '/# >>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<</ {
/crowdsec.*\.log:/ s/^\([[:space:]]*\)#-/\1-/
}' "$compose"
;;
off)
runFileOp sed -i '/# >>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<</ {
/crowdsec.*\.log:/ s/^\([[:space:]]*\)-/\1#-/
}' "$compose"
;;
*) isError "crowdsecToggleLibrePortalLogMounts: bad mode '$mode'"; return 1 ;;
esac
if runFileOp docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^libreportal-service$'; then
isNotice "Recreating libreportal so log mount toggle takes effect..."
( cd "$(webuiDir)" && runAsManager docker compose up -d >/dev/null 2>&1 ) || true
fi
}
installCrowdsecHost()
{
# Make /var/log/crowdsec*.log world-readable so the libreportal container
# (UID 1001) can tail them via the bind-mount we're about to enable.
runCrowdsec touch-host-logs
crowdsecToggleLibrePortalLogMounts on
local desired_state="${CFG_CROWDSEC_ENABLED:-true}"
local is_installed="false"
command -v cscli >/dev/null 2>&1 && is_installed="true"
if [[ "$desired_state" == "true" && "$is_installed" == "false" ]]; then
isHeader "Install CrowdSec"
((menu_number++))
echo ""
echo "---- $menu_number. Installing the CrowdSec agent + firewall bouncer."
echo ""
isNotice "First-time install ~30-70 MB GeoLite2 DB + parser hub, 1-3 mins."
# One-shot: adds the apt repo, installs both packages, enables both
# services, installs the crowdsecurity/linux + /sshd collections, then
# reloads the agent. All of it lives in libreportal-crowdsec so the
# manager never needs `sudo apt-get` / `sudo bash`.
local result; result=$(runCrowdsec install)
checkSuccess "Installing CrowdSec agent + firewall bouncer + baseline collections"
((menu_number++))
echo ""
echo "---- $menu_number. Community blocklist (CAPI) toggle."
echo ""
# CAPI registration is what subscribes to the community blocklist
# AND sends anonymous attack signals back. apt postinst registers
# by default; honour CFG_CROWDSEC_COMMUNITY_BLOCKLIST=false by
# unregistering. Idempotent on either branch.
local community_blocklist="${CFG_CROWDSEC_COMMUNITY_BLOCKLIST:-true}"
if [[ "$community_blocklist" == "true" ]]; then
if runCrowdsec capi status 2>&1 | grep -qi 'You can successfully'; then
isNotice "Community blocklist already registered."
else
local result; result=$(runCrowdsec capi register 2>&1)
checkSuccess "Registered with CrowdSec Central API (community blocklist)"
fi
else
local result; result=$(runCrowdsec capi unregister 2>&1)
checkSuccess "Unregistered from CrowdSec Central API (community blocklist disabled)"
fi
((menu_number++))
echo ""
echo "---- $menu_number. SaaS Console enrollment toggle."
echo ""
# `console enroll <token>` registers this agent with the hosted
# dashboard at app.crowdsec.net. Idempotent: if already enrolled,
# skip. If toggled off, disenroll. Quietly skipped when the flag
# is on but the token field is empty (user hasn't pasted one yet).
local console_enroll="${CFG_CROWDSEC_CONSOLE_ENROLL:-false}"
local console_token="${CFG_CROWDSEC_CONSOLE_TOKEN:-}"
local enrolled=false
runCrowdsec console status 2>&1 | grep -qi 'enrolled' && enrolled=true
if [[ "$console_enroll" == "true" ]]; then
if [[ -z "$console_token" ]]; then
isNotice "Console enrollment ON but CFG_CROWDSEC_CONSOLE_TOKEN is empty — paste your token from app.crowdsec.net to complete."
elif [[ "$enrolled" == true ]]; then
isNotice "Already enrolled with the SaaS console — skipping."
else
local result; result=$(runCrowdsec console enroll "$console_token" 2>&1)
checkSuccess "Enrolled with app.crowdsec.net SaaS console"
fi
else
if [[ "$enrolled" == true ]]; then
local result; result=$(runCrowdsec console disenroll 2>&1)
checkSuccess "Disenrolled from app.crowdsec.net SaaS console"
else
isNotice "SaaS console enrollment disabled — skipping."
fi
fi
((menu_number++))
echo ""
echo "---- $menu_number. Wiring LAPI for Traefik bouncer access."
echo ""
# Bind LAPI to all interfaces so the Traefik container can reach it
# via host.docker.internal:host-gateway. The bouncer API key is
# required (HTTP 401 without it), so internet exposure is gated.
# External access on 8080 should still be blocked at UFW.
local lapi_host="${CFG_CROWDSEC_LAPI_HOST:-0.0.0.0:8080}"
local bind_result
bind_result=$(runCrowdsec bind-lapi "$lapi_host" 2>&1)
if [[ "$bind_result" == "ALREADY_BOUND" ]]; then
isNotice "LAPI already bound to ${lapi_host} — skipping."
else
checkSuccess "LAPI bound to ${lapi_host}"
runCrowdsec services restart
checkSuccess "CrowdSec restarted"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Prometheus metrics endpoint."
echo ""
# When monitoring is on, bind CrowdSec's Prometheus metrics endpoint to
# a docker-reachable address (the 127.0.0.1 default can't be scraped
# from the Prometheus container). When off, rebind to localhost if a
# prior run opened it. The helper does the scoped edit in the
# prometheus: block.
local mon_enabled="${CFG_CROWDSEC_MONITORING:-false}"
local prom_listen="${CFG_CROWDSEC_PROMETHEUS_LISTEN:-0.0.0.0:6060}"
local prom_addr="${prom_listen%%:*}"
local prom_port="${prom_listen##*:}"
if [[ "$mon_enabled" == "true" ]]; then
local result; result=$(runCrowdsec prometheus on "$prom_addr" "$prom_port")
checkSuccess "CrowdSec metrics endpoint bound to ${prom_listen}"
runCrowdsec services restart
checkSuccess "CrowdSec restarted"
else
local result; result=$(runCrowdsec prometheus off)
checkSuccess "CrowdSec metrics endpoint rebound to 127.0.0.1 (monitoring off)"
runCrowdsec services restart
checkSuccess "CrowdSec restarted"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Traefik bouncer API key."
echo ""
# Generate a dedicated bouncer key for Traefik. Two sinks for the value:
# 1) /etc/crowdsec/traefik_bouncer.key — raw key, bind-mounted into
# the Traefik container read-only; the plugin reads it via
# crowdsecLapiKeyFile. /etc/crowdsec/ is outside the framework's
# sourceScanFiles sweep so a bare key file is safe here.
# 2) the deployed crowdsec.config — CFG_CROWDSEC_TRAEFIK_LAPI_KEY line,
# sourced by the framework and visible on the config page. Editing
# the CFG var manually does not re-register the bouncer; this is a
# visibility surface, not the auth source of truth.
# This used to point at ${configs_dir}security/security_crowdsec,
# which no template ever shipped — so the file never existed, the
# -f guard always failed, and the key was never mirrored. The key
# is declared in crowdsec.config, so that is where it belongs.
# The helper handles cscli + tee + chown + chmod atomically.
local cfg_file="${containers_dir}crowdsec/crowdsec.config"
local key_file="/etc/crowdsec/traefik_bouncer.key"
# What the config currently holds, if anything. Quotes and whitespace
# stripped: updateConfigOption writes the value quoted.
local recorded_key=""
if [[ -f "$cfg_file" ]]; then
recorded_key=$(grep -m1 '^CFG_CROWDSEC_TRAEFIK_LAPI_KEY=' "$cfg_file" 2>/dev/null | cut -d= -f2-)
recorded_key="${recorded_key%%#*}"
recorded_key="${recorded_key//\"/}"
recorded_key="${recorded_key//[[:space:]]/}"
fi
local init_result bouncer_key=""
init_result=$(runCrowdsec bouncer-traefik-init 2>&1)
if [[ "$init_result" == "EXISTS" ]]; then
isNotice "Bouncer 'traefik-bouncer' already registered — leaving the existing key file untouched at $key_file."
# Self-heal. cscli cannot show an existing bouncer's key, so an
# install whose mirror never landed (every install before the write
# target was corrected) had no way back to the value short of
# re-registering the bouncer — which invalidates the key Traefik is
# already using. The helper leaves the key file owned by the manager
# at 0600 precisely so this layer can read it back.
if [[ -z "$recorded_key" ]]; then
if [[ -r "$key_file" ]]; then
bouncer_key=$(tr -d '\r\n' < "$key_file")
[[ -n "$bouncer_key" ]] \
&& isNotice "Config had no bouncer key — recovering it from $key_file." \
|| isNotice "$key_file is empty — cannot recover the bouncer key. Run the \"Rotate Traefik Bouncer Key\" tool."
else
isNotice "Config has no bouncer key and $key_file is not readable — run the \"Rotate Traefik Bouncer Key\" tool to issue a new one."
fi
fi
elif [[ "$init_result" == GENERATED:* ]]; then
bouncer_key="${init_result#GENERATED:}"
checkSuccess "Traefik bouncer API key generated"
else
isNotice "Failed to generate bouncer key: $init_result"
isNotice "Traefik integration won't authenticate. Re-run installCrowdsecHost to retry."
fi
# Mirror the key into the live config file so it's visible / editable via
# the framework's config page like any other CFG_* setting.
# updateConfigOption rather than a hand-rolled sed: it escapes the value,
# routes the write through the user that owns the containers tree, and
# re-sources so the new key is live in this same run. Skipped when the
# config already agrees, so a normal reinstall writes nothing.
if [[ -n "$bouncer_key" && "$bouncer_key" != "$recorded_key" ]]; then
if [[ -f "$cfg_file" ]]; then
updateConfigOption "CFG_CROWDSEC_TRAEFIK_LAPI_KEY" "$bouncer_key" "$cfg_file"
else
isNotice "crowdsec.config not deployed yet — key applied on next install."
fi
fi
((menu_number++))
echo ""
echo "---- $menu_number. Verifying CrowdSec / host firewall coexistence."
echo ""
# The firewall bouncer needs a moment to install its nftables table
# after enable. Poll up to ~10s before deciding it's missing.
local _wait=0
until runSystem nft list tables 2>/dev/null | grep -qiE 'crowdsec' || [[ $_wait -ge 10 ]]; do
sleep 1; _wait=$((_wait+1))
done
if ! runSystem nft list tables 2>/dev/null | grep -qiE 'crowdsec'; then
isNotice "CrowdSec nftables table not yet present after ${_wait}s. Bouncer may still be starting; re-run the verification Tools action in a minute if rules don't appear."
else
local cs_prio ufw_prio
cs_prio=$(runSystem nft list ruleset 2>/dev/null | awk '/table .* crowdsec/{flag=1} flag && /priority/{match($0,/priority [-0-9]+/); print substr($0,RSTART+9,RLENGTH-9); exit}')
ufw_prio=$(runSystem nft list ruleset 2>/dev/null | awk '/chain ufw[a-z0-9-]*input/{flag=1} flag && /priority/{match($0,/priority [-0-9]+/); print substr($0,RSTART+9,RLENGTH-9); exit}')
if [[ -z "$ufw_prio" ]]; then
isSuccessful "UFW not in nftables — no ordering needed (CrowdSec prio: ${cs_prio:-?})."
elif [[ -n "$cs_prio" && "$cs_prio" -lt "$ufw_prio" ]]; then
isSuccessful "Chain priority correct: CrowdSec ($cs_prio) runs before UFW ($ufw_prio) — bans take precedence."
else
isNotice "WARNING: CrowdSec priority (${cs_prio:-unknown}) is not lower than UFW ($ufw_prio)."
isNotice " Packets accepted by UFW first won't reach CrowdSec drop rules."
isNotice " Fix: run the 'crowdsec_fix_priority' Tools action, or manually edit"
isNotice " /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml — set"
isNotice " nftables.ipv4.priority and nftables.ipv6.priority to -100, then"
isNotice " 'sudo systemctl restart crowdsec-firewall-bouncer'."
fi
fi
isSuccessful "CrowdSec installed. Console enrollment OFF (no signals sent). Opt in via Tools tab for community blocklists."
menu_number=0
cd
elif [[ "$desired_state" == "true" && "$is_installed" == "true" ]]; then
# Already installed — make sure services are running. Quiet path (no header)
# so re-runs of pre-install don't spam the log when state already matches.
if ! systemctl is-active --quiet crowdsec; then
isHeader "Enable CrowdSec"
((menu_number++))
echo ""
echo "---- $menu_number. Re-enabling CrowdSec services."
echo ""
local result; result=$(runCrowdsec services enable)
checkSuccess "Enabling CrowdSec agent + firewall bouncer"
isSuccessful "CrowdSec services re-enabled."
menu_number=0
fi
elif [[ "$desired_state" != "true" && "$is_installed" == "true" ]]; then
# User flipped CFG_CROWDSEC_ENABLED away from "true" — disable, don't
# uninstall. Package stays so flipping back is fast; explicit uninstall is
# a separate Tools action.
isHeader "Disable CrowdSec"
((menu_number++))
echo ""
echo "---- $menu_number. Stopping and disabling CrowdSec services."
echo ""
local result; result=$(runCrowdsec services disable)
checkSuccess "Disabling CrowdSec agent + firewall bouncer"
isSuccessful "CrowdSec disabled. Package remains installed — set CFG_CROWDSEC_ENABLED=true to re-enable, or uninstall via the Tools tab."
menu_number=0
fi
}