From 7513a62fde5b6832c2687553b46395f053cb0233 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 22:05:39 +0100 Subject: [PATCH] feat(crowdsec): migrate host-install to a dedicated libreportal-crowdsec helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrowdSec's host-side install (the agent + nftables bouncer the LibrePortal Traefik plugin talks to) had stayed on blanket sudo throughout the rootless + de-sudo hardening: `sudo apt-get install crowdsec`, `curl | sudo bash`, `sudo sed -i /etc/crowdsec/config.yaml`, `sudo touch + sudo chmod /var/log/ crowdsec*.log`, `echo $key | sudo tee /etc/crowdsec/traefik_bouncer.key`, plus `sudo cscli capi register / console enroll / bouncers add`. None of those are in the scoped LP_HELPERS / LP_SYSTEM sudoers grant the manager now holds, so any user who enabled crowdsec would have hit hard sudo failures on every privileged step. Follow the libreportal-appcfg / libreportal-bininstall pattern: one new root-owned helper at /usr/local/lib/libreportal/libreportal-crowdsec that does every privileged op behind a fixed action vocabulary with strict argument validation. The manager calls in via runCrowdsec — the scoped sudoers grants exactly one binary, the same trust boundary the other helpers rely on. Actions: install apt repo + agent + firewall-bouncer + enable + crowdsecurity/{linux,sshd} collections + reload (idempotent — skips parts already in place) services enable | disable | restart capi register | unregister | status console enroll | disenroll | status token format strictly validated bouncer-traefik-init cscli register + write the manager-owned key file atomically (returns EXISTS or GENERATED:) bouncer-priority bouncer yaml nftables priority → -100 (moved from libreportal-appcfg; one helper for every crowdsec root op) bind-lapi flip listen_uri to 0.0.0.0:8080 in config.yaml prometheus flip the prometheus block (validated addr/port) touch-host-logs create + chmod 0644 /var/log/crowdsec*.log so the libreportal container can tail them Wired in via: - new sudoers Cmnd_Alias entry for the helper in LP_HELPERS - new helper baked alongside the others by initRootHelpers (replaces __SYSTEM_DIR__ / __CONTAINERS_DIR__ / __MANAGER__ at install, with safe runtime fallbacks if unbaked) - new runCrowdsec dispatch in scripts/docker/command/run_privileged.sh containers/crowdsec/scripts/crowdsec_install_host.sh now drives the whole flow through runCrowdsec — every `sudo …` is gone, the compose-toggle sed uses runFileOp, and the security_crowdsec CFG mirror uses runInstallOp (configs/ is manager-owned). Net: install script shrinks ~80 lines while gaining a single auditable trust boundary. crowdsec_fix_priority.sh swung over to runCrowdsec bouncer-priority too — the appcfg crowdsec_priority action drops out cleanly. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- .../crowdsec/scripts/crowdsec_fix_priority.sh | 4 +- .../crowdsec/scripts/crowdsec_install_host.sh | 190 ++++++---------- init.sh | 5 +- scripts/docker/command/run_privileged.sh | 13 +- .../source/files/arrays/function_manifest.sh | 3 + scripts/system/libreportal-appcfg | 19 +- scripts/system/libreportal-crowdsec | 202 ++++++++++++++++++ 7 files changed, 290 insertions(+), 146 deletions(-) create mode 100644 scripts/system/libreportal-crowdsec diff --git a/containers/crowdsec/scripts/crowdsec_fix_priority.sh b/containers/crowdsec/scripts/crowdsec_fix_priority.sh index 67d7868..44f7254 100644 --- a/containers/crowdsec/scripts/crowdsec_fix_priority.sh +++ b/containers/crowdsec/scripts/crowdsec_fix_priority.sh @@ -8,8 +8,8 @@ appCrowdSecFixPriority() { fi # The bouncer yaml is root-owned under /etc/crowdsec; the backup + nftables - # ipv4/ipv6 priority rewrite (to -100) runs in the root-owned appcfg helper. - runAppCfg crowdsec-priority + # ipv4/ipv6 priority rewrite (to -100) runs in the root-owned crowdsec helper. + runCrowdsec bouncer-priority checkSuccess "Patched nftables priority to -100 in $cfg" runSystem systemctl restart crowdsec-firewall-bouncer diff --git a/containers/crowdsec/scripts/crowdsec_install_host.sh b/containers/crowdsec/scripts/crowdsec_install_host.sh index c65c9dd..7576632 100644 --- a/containers/crowdsec/scripts/crowdsec_install_host.sh +++ b/containers/crowdsec/scripts/crowdsec_install_host.sh @@ -10,12 +10,14 @@ crowdsecToggleLibrePortalLogMounts() { case "$mode" in on) - sudo sed -i '/# >>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <</dev/null || true - sudo chmod 0644 "$_l" 2>/dev/null || true - done + # 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}" @@ -47,53 +46,16 @@ installCrowdsecHost() ((menu_number++)) echo "" - echo "---- $menu_number. Adding the CrowdSec apt repository." - echo "" - - local result=$(curl -fsSL https://install.crowdsec.net | sudo bash) - checkSuccess "Adding CrowdSec repository" - - ((menu_number++)) - echo "" - echo "---- $menu_number. Installing the CrowdSec agent." + echo "---- $menu_number. Installing the CrowdSec agent + firewall bouncer." echo "" isNotice "First-time install ~30-70 MB GeoLite2 DB + parser hub, 1-3 mins." - local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q crowdsec &1) - checkSuccess "Installing CrowdSec package" - - ((menu_number++)) - echo "" - echo "---- $menu_number. Installing the CrowdSec firewall bouncer." - echo "" - - local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q crowdsec-firewall-bouncer-nftables &1) - checkSuccess "Installing CrowdSec firewall bouncer (nftables)" - - ((menu_number++)) - echo "" - echo "---- $menu_number. Enabling CrowdSec services." - echo "" - - local result=$(runSystem systemctl enable --now crowdsec) - checkSuccess "Enabling CrowdSec agent" - - local result=$(runSystem systemctl enable --now crowdsec-firewall-bouncer) - checkSuccess "Enabling CrowdSec firewall bouncer" - - ((menu_number++)) - echo "" - echo "---- $menu_number. Installing baseline collections." - echo "" - - local result=$(runSystem cscli collections install crowdsecurity/linux) - checkSuccess "Installing crowdsecurity/linux collection" - - local result=$(runSystem cscli collections install crowdsecurity/sshd) - checkSuccess "Installing crowdsecurity/sshd collection" - - local result=$(runSystem systemctl reload crowdsec) - checkSuccess "Reloading CrowdSec to pick up collections" + # 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=$(runCrowdsec install) + checkSuccess "Installing CrowdSec agent + firewall bouncer + baseline collections" ((menu_number++)) echo "" @@ -106,14 +68,14 @@ installCrowdsecHost() # unregistering. Idempotent on either branch. local community_blocklist="${CFG_CROWDSEC_COMMUNITY_BLOCKLIST:-true}" if [[ "$community_blocklist" == "true" ]]; then - if runSystem cscli capi status 2>&1 | grep -qi 'You can successfully'; then + if runCrowdsec capi status 2>&1 | grep -qi 'You can successfully'; then isNotice "Community blocklist already registered." else - local result=$(runSystem cscli capi register 2>&1) + local result=$(runCrowdsec capi register 2>&1) checkSuccess "Registered with CrowdSec Central API (community blocklist)" fi else - local result=$(runSystem cscli capi unregister 2>&1) + local result=$(runCrowdsec capi unregister 2>&1) checkSuccess "Unregistered from CrowdSec Central API (community blocklist disabled)" fi @@ -122,26 +84,26 @@ installCrowdsecHost() echo "---- $menu_number. SaaS Console enrollment toggle." echo "" - # cscli console enroll registers this agent with the hosted + # `console enroll ` 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 - runSystem cscli console status 2>&1 | grep -qi 'enrolled' && enrolled=true + 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=$(runSystem cscli console enroll "$console_token" 2>&1) + local result=$(runCrowdsec console enroll "$console_token" 2>&1) checkSuccess "Enrolled with app.crowdsec.net SaaS console" fi else if [[ "$enrolled" == true ]]; then - local result=$(runSystem cscli console disenroll 2>&1) + local result=$(runCrowdsec console disenroll 2>&1) checkSuccess "Disenrolled from app.crowdsec.net SaaS console" else isNotice "SaaS console enrollment disabled — skipping." @@ -157,14 +119,14 @@ installCrowdsecHost() # 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_cfg="/etc/crowdsec/config.yaml" - if [[ -f "$lapi_cfg" ]] && ! sudo grep -qE 'listen_uri:[[:space:]]*0\.0\.0\.0:8080' "$lapi_cfg"; then - sudo sed -i 's|listen_uri:.*|listen_uri: 0.0.0.0:8080|' "$lapi_cfg" - checkSuccess "LAPI bound to 0.0.0.0:8080" - runSystem systemctl restart crowdsec - checkSuccess "CrowdSec restarted" - else + local bind_result + bind_result=$(runCrowdsec bind-lapi 2>&1) + if [[ "$bind_result" == "ALREADY_BOUND" ]]; then isNotice "LAPI already bound to 0.0.0.0:8080 — skipping." + else + checkSuccess "LAPI bound to 0.0.0.0:8080" + runCrowdsec services restart + checkSuccess "CrowdSec restarted" fi ((menu_number++)) @@ -174,72 +136,62 @@ installCrowdsecHost() # 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). The sed is scoped to the prometheus: - # block. When off, rebind to localhost if a prior run opened it. - local cs_cfg="/etc/crowdsec/config.yaml" + # 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" && -f "$cs_cfg" ]]; then - if ! sudo grep -qE "listen_addr:[[:space:]]*${prom_addr}" "$cs_cfg"; then - sudo sed -i "/^prometheus:/,/^[^[:space:]#]/ { - s|enabled:.*|enabled: true| - s|listen_addr:.*|listen_addr: ${prom_addr}| - s|listen_port:.*|listen_port: ${prom_port}| - }" "$cs_cfg" - checkSuccess "CrowdSec metrics endpoint bound to ${prom_listen}" - runSystem systemctl restart crowdsec - checkSuccess "CrowdSec restarted" - else - isNotice "CrowdSec metrics already bound to ${prom_addr} — skipping." - fi - elif [[ -f "$cs_cfg" ]] && sudo grep -qE 'listen_addr:[[:space:]]*0\.0\.0\.0' "$cs_cfg"; then - sudo sed -i "/^prometheus:/,/^[^[:space:]#]/ s|listen_addr:.*|listen_addr: 127.0.0.1|" "$cs_cfg" - checkSuccess "CrowdSec metrics endpoint rebound to 127.0.0.1 (monitoring off)" - runSystem systemctl restart crowdsec + if [[ "$mon_enabled" == "true" ]]; then + local result=$(runCrowdsec prometheus on "$prom_addr" "$prom_port") + checkSuccess "CrowdSec metrics endpoint bound to ${prom_listen}" + runCrowdsec services restart checkSuccess "CrowdSec restarted" else - isNotice "Monitoring off — CrowdSec metrics endpoint left at its default." + local result=$(runCrowdsec prometheus off) + checkSuccess "CrowdSec metrics endpoint rebound to 127.0.0.1 (monitoring off)" + runCrowdsec services restart + checkSuccess "CrowdSec restarted" fi - # Generate a dedicated bouncer key for Traefik (idempotent: skip if - # already registered). Two sinks for the value: + ((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) /docker/configs/network/network_crowdsec — CFG_CROWDSEC_TRAEFIK_LAPI_KEY + # 2) ${configs_dir}security/security_crowdsec — 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 # (use the rotate Tools action for that); this is a visibility # surface, not the auth source of truth. - local key_file="/etc/crowdsec/traefik_bouncer.key" - local cfg_file="${configs_dir}security/security_crowdsec" + # The helper handles cscli + tee + chown + chmod atomically. + local init_result + init_result=$(runCrowdsec bouncer-traefik-init 2>&1) + if [[ "$init_result" == "EXISTS" ]]; then + isNotice "Bouncer 'traefik-bouncer' already registered — leaving existing key file untouched at /etc/crowdsec/traefik_bouncer.key." + elif [[ "$init_result" == GENERATED:* ]]; then + local bouncer_key="${init_result#GENERATED:}" + checkSuccess "Traefik bouncer API key generated" - if ! runSystem cscli bouncers list -o raw 2>/dev/null | grep -q '^traefik-bouncer'; then - local bouncer_key - bouncer_key=$(runSystem cscli bouncers add traefik-bouncer -o raw 2>&1 | tail -1) - if [[ -n "$bouncer_key" && "$bouncer_key" != *"error"* ]]; then - echo "$bouncer_key" | sudo tee "$key_file" >/dev/null - sudo chown "$sudo_user_name:$sudo_user_name" "$key_file" - sudo chmod 0600 "$key_file" - checkSuccess "Traefik bouncer API key generated" - - # Write the key into the live config file so it's visible / - # editable via the framework's config page like any other - # CFG_* setting. - if [[ -f "$cfg_file" ]]; then - sudo sed -i "s|^CFG_CROWDSEC_TRAEFIK_LAPI_KEY=.*|CFG_CROWDSEC_TRAEFIK_LAPI_KEY=${bouncer_key}|" "$cfg_file" - checkSuccess "Key mirrored to CFG_CROWDSEC_TRAEFIK_LAPI_KEY" - else - isNotice "Live config not present yet — key applied on next install." - 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. configs/ is manager-owned, so runInstallOp suffices. + local cfg_file="${configs_dir}security/security_crowdsec" + if [[ -f "$cfg_file" ]]; then + runInstallOp sed -i "s|^CFG_CROWDSEC_TRAEFIK_LAPI_KEY=.*|CFG_CROWDSEC_TRAEFIK_LAPI_KEY=${bouncer_key}|" "$cfg_file" + checkSuccess "Key mirrored to CFG_CROWDSEC_TRAEFIK_LAPI_KEY" else - isNotice "Failed to generate bouncer key — Traefik integration won't authenticate. Re-run installCrowdsecHost to retry." + isNotice "Live config not present yet — key applied on next install." fi else - isNotice "Bouncer 'traefik-bouncer' already registered — leaving existing key file untouched at $key_file." + isNotice "Failed to generate bouncer key: $init_result" + isNotice "Traefik integration won't authenticate. Re-run installCrowdsecHost to retry." fi ((menu_number++)) @@ -291,11 +243,8 @@ installCrowdsecHost() echo "---- $menu_number. Re-enabling CrowdSec services." echo "" - local result=$(runSystem systemctl enable --now crowdsec) - checkSuccess "Enabling CrowdSec agent" - - local result=$(runSystem systemctl enable --now crowdsec-firewall-bouncer) - checkSuccess "Enabling CrowdSec firewall bouncer" + local result=$(runCrowdsec services enable) + checkSuccess "Enabling CrowdSec agent + firewall bouncer" isSuccessful "CrowdSec services re-enabled." menu_number=0 @@ -312,11 +261,8 @@ installCrowdsecHost() echo "---- $menu_number. Stopping and disabling CrowdSec services." echo "" - local result=$(runSystem systemctl disable --now crowdsec-firewall-bouncer 2>&1) - checkSuccess "Disabling CrowdSec firewall bouncer" - - local result=$(runSystem systemctl disable --now crowdsec 2>&1) - checkSuccess "Disabling CrowdSec agent" + local 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 diff --git a/init.sh b/init.sh index 80b2ea9..729965c 100755 --- a/init.sh +++ b/init.sh @@ -892,7 +892,8 @@ Cmnd_Alias LP_HELPERS = ${lp_lib_dir}/libreportal-ownership, \\ ${lp_lib_dir}/libreportal-socket, \\ ${lp_lib_dir}/libreportal-svc, \\ ${lp_lib_dir}/libreportal-bininstall, \\ - ${lp_lib_dir}/libreportal-appcfg + ${lp_lib_dir}/libreportal-appcfg, \\ + ${lp_lib_dir}/libreportal-crowdsec Cmnd_Alias LP_SYSTEM = /usr/bin/systemctl, /usr/sbin/ufw, /usr/local/bin/ufw-docker, \\ /usr/sbin/nft, /usr/sbin/sysctl, /sbin/sysctl, \\ /usr/bin/loginctl, /usr/sbin/service @@ -932,7 +933,7 @@ initRootHelpers() # sudo's (the trust boundary the scoped sudoers relies on). sudo install -d -m 0755 -o root -g root "$lp_lib_dir" local helper helper_src helper_dst helper_tmp - for helper in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket libreportal-svc libreportal-bininstall libreportal-appcfg; do + for helper in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket libreportal-svc libreportal-bininstall libreportal-appcfg libreportal-crowdsec; do helper_src="$script_dir/scripts/system/$helper" helper_dst="$lp_lib_dir/$helper" if [[ ! -f "$helper_src" ]]; then diff --git a/scripts/docker/command/run_privileged.sh b/scripts/docker/command/run_privileged.sh index 53d7a80..3c8a914 100644 --- a/scripts/docker/command/run_privileged.sh +++ b/scripts/docker/command/run_privileged.sh @@ -136,10 +136,19 @@ runSvc() { _runRootHelper libreportal-svc "$@"; } runBinInstall() { _runRootHelper libreportal-bininstall "$@"; } # App config-file rewrites owned by in-container uids / root /etc: -# {adguard-auth |crowdsec-priority| -# owncloud-config } +# {adguard-auth |owncloud-config | +# wireguard-ip-forward} runAppCfg() { _runRootHelper libreportal-appcfg "$@"; } +# CrowdSec host-side privileged ops — apt install of the agent + firewall +# bouncer, cscli register/enroll, /etc/crowdsec/* edits, /var/log/crowdsec*.log +# touch+chmod, /etc/crowdsec/traefik_bouncer.key write. One audit funnel for +# every operation the host-side CrowdSec install needs the manager can't drop: +# {install|services |capi +# |console |disenroll|status>|bouncer-traefik-init| +# bouncer-priority|bind-lapi|prometheus |off>|touch-host-logs} +runCrowdsec() { _runRootHelper libreportal-crowdsec "$@"; } + # Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc # edits). Needs real root in both modes; funnelled through one place so it can # later be confined to a scoped sudoers allowlist. diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index f87a825..d296d46 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -720,6 +720,7 @@ declare -gA LP_FN_MAP=( [runAsManager]="docker/command/run_privileged.sh" [runBackupOp]="docker/command/run_privileged.sh" [runBinInstall]="docker/command/run_privileged.sh" + [runCrowdsec]="docker/command/run_privileged.sh" [runFileOp]="docker/command/run_privileged.sh" [runFileWrite]="docker/command/run_privileged.sh" [runInstallOp]="docker/command/run_privileged.sh" @@ -1582,6 +1583,7 @@ declare -gA LP_FN_ROOT=( [runAsManager]="scripts" [runBackupOp]="scripts" [runBinInstall]="scripts" + [runCrowdsec]="scripts" [runFileOp]="scripts" [runFileWrite]="scripts" [runInstallOp]="scripts" @@ -2462,6 +2464,7 @@ runAppCfg() { source "${install_scripts_dir}docker/command/run_privileged.sh"; r runAsManager() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runAsManager "$@"; } runBackupOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runBackupOp "$@"; } runBinInstall() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runBinInstall "$@"; } +runCrowdsec() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runCrowdsec "$@"; } runFileOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runFileOp "$@"; } runFileWrite() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runFileWrite "$@"; } runInstallOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runInstallOp "$@"; } diff --git a/scripts/system/libreportal-appcfg b/scripts/system/libreportal-appcfg index a72fd32..fe95886 100644 --- a/scripts/system/libreportal-appcfg +++ b/scripts/system/libreportal-appcfg @@ -68,22 +68,6 @@ EOF sysctl --system >/dev/null 2>&1 || sysctl -p "$dropin" >/dev/null 2>&1 || true } -# --- CrowdSec: set nftables ipv4/ipv6 priority to -100 in the bouncer yaml ------ -crowdsec_priority() { - local cfg="/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml" - [[ -f "$cfg" ]] || { echo "libreportal-appcfg: $cfg not found" >&2; return 1; } - cp "$cfg" "${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 } - ' "$cfg" > "${cfg}.new" && mv "${cfg}.new" "$cfg" -} - # --- ownCloud: normalise trusted_domains + overwrite.cli.url in config.php ------ owncloud_config() { local public="$1" host_setup="$2" ip_setup="$3" public_ip="$4" @@ -143,8 +127,7 @@ EOL action="${1:-}"; shift 2>/dev/null || true case "$action" in adguard-auth) adguard_auth "${1:-}" "${2:-}" ;; - crowdsec-priority) crowdsec_priority ;; owncloud-config) owncloud_config "${1:-}" "${2:-}" "${3:-}" "${4:-}" ;; wireguard-ip-forward) wireguard_ip_forward ;; - *) echo "usage: libreportal-appcfg {adguard-auth |crowdsec-priority|owncloud-config |wireguard-ip-forward}" >&2; exit 2 ;; + *) echo "usage: libreportal-appcfg {adguard-auth |owncloud-config |wireguard-ip-forward}" >&2; exit 2 ;; esac diff --git a/scripts/system/libreportal-crowdsec b/scripts/system/libreportal-crowdsec new file mode 100644 index 0000000..31c5c4f --- /dev/null +++ b/scripts/system/libreportal-crowdsec @@ -0,0 +1,202 @@ +#!/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 [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 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 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 |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 — 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 |disenroll|status}" >&2; return 2 ;; + esac +} + +# --- bouncer-traefik-init: cscli register + write key file -------------------- +# Idempotent: if the bouncer is already registered, skips and prints the existing +# key-file marker so callers can decide whether to keep or rotate. Returns the +# fresh key on stdout when newly generated, "EXISTS" when already registered. +crowdsec_bouncer_traefik_init() { + if cscli bouncers list -o raw 2>/dev/null | grep -q '^traefik-bouncer'; then + echo "EXISTS" + return 0 + fi + 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" +} + +# --- bind-lapi: set listen_uri to 0.0.0.0:8080 in config.yaml ----------------- +# Traefik talks to LAPI via host.docker.internal:8080. Bouncer API key gates +# external access (HTTP 401 without it). +crowdsec_bind_lapi() { + [[ -f "$CFG_FILE" ]] || { echo "libreportal-crowdsec: $CFG_FILE not found" >&2; return 1; } + if grep -qE 'listen_uri:[[:space:]]*0\.0\.0\.0:8080' "$CFG_FILE"; then + echo "ALREADY_BOUND"; return 0 + fi + sed -i 's|listen_uri:.*|listen_uri: 0.0.0.0:8080|' "$CFG_FILE" +} + +# --- prometheus {on |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 |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-priority) crowdsec_bouncer_priority ;; + bind-lapi) crowdsec_bind_lapi ;; + prometheus) crowdsec_prometheus "${1:-}" "${2:-}" "${3:-}" ;; + touch-host-logs) crowdsec_touch_host_logs ;; + *) + echo "usage: libreportal-crowdsec {install|services |capi |console |disenroll|status>|bouncer-traefik-init|bouncer-priority|bind-lapi|prometheus |off>|touch-host-logs}" >&2 + exit 2 + ;; +esac