feat(crowdsec): migrate host-install to a dedicated libreportal-crowdsec helper

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 <verb>       enable | disable | restart
  capi <verb>           register | unregister | status
  console <verb>        enroll <token> | disenroll | status
                        token format strictly validated
  bouncer-traefik-init  cscli register + write the manager-owned key file
                        atomically (returns EXISTS or GENERATED:<key>)
  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 <on…|off>  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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-26 22:05:39 +01:00
parent 10dc7d0bc0
commit 7513a62fde
7 changed files with 290 additions and 146 deletions

View File

@ -8,8 +8,8 @@ appCrowdSecFixPriority() {
fi fi
# The bouncer yaml is root-owned under /etc/crowdsec; the backup + nftables # 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. # ipv4/ipv6 priority rewrite (to -100) runs in the root-owned crowdsec helper.
runAppCfg crowdsec-priority runCrowdsec bouncer-priority
checkSuccess "Patched nftables priority to -100 in $cfg" checkSuccess "Patched nftables priority to -100 in $cfg"
runSystem systemctl restart crowdsec-firewall-bouncer runSystem systemctl restart crowdsec-firewall-bouncer

View File

@ -10,12 +10,14 @@ crowdsecToggleLibrePortalLogMounts() {
case "$mode" in case "$mode" in
on) on)
sudo sed -i '/# >>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<</ { # 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-/ /crowdsec.*\.log:/ s/^\([[:space:]]*\)#-/\1-/
}' "$compose" }' "$compose"
;; ;;
off) off)
sudo sed -i '/# >>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<</ { runFileOp sed -i '/# >>> crowdsec-host-logs >>>/,/# <<< crowdsec-host-logs <<</ {
/crowdsec.*\.log:/ s/^\([[:space:]]*\)-/\1#-/ /crowdsec.*\.log:/ s/^\([[:space:]]*\)-/\1#-/
}' "$compose" }' "$compose"
;; ;;
@ -30,12 +32,9 @@ crowdsecToggleLibrePortalLogMounts() {
installCrowdsecHost() installCrowdsecHost()
{ {
# Touch + chmod 0644 the host log files so the libreportal container # Make /var/log/crowdsec*.log world-readable so the libreportal container
# (UID 1001) can read them. Then enable the bind-mounts. # (UID 1001) can tail them via the bind-mount we're about to enable.
for _l in /var/log/crowdsec.log /var/log/crowdsec-firewall-bouncer.log; do runCrowdsec touch-host-logs
sudo touch "$_l" 2>/dev/null || true
sudo chmod 0644 "$_l" 2>/dev/null || true
done
crowdsecToggleLibrePortalLogMounts on crowdsecToggleLibrePortalLogMounts on
local desired_state="${CFG_CROWDSEC_ENABLED:-true}" local desired_state="${CFG_CROWDSEC_ENABLED:-true}"
@ -47,53 +46,16 @@ installCrowdsecHost()
((menu_number++)) ((menu_number++))
echo "" echo ""
echo "---- $menu_number. Adding the CrowdSec apt repository." echo "---- $menu_number. Installing the CrowdSec agent + firewall bouncer."
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 "" echo ""
isNotice "First-time install ~30-70 MB GeoLite2 DB + parser hub, 1-3 mins." 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 </dev/null 2>&1) # One-shot: adds the apt repo, installs both packages, enables both
checkSuccess "Installing CrowdSec package" # services, installs the crowdsecurity/linux + /sshd collections, then
# reloads the agent. All of it lives in libreportal-crowdsec so the
((menu_number++)) # manager never needs `sudo apt-get` / `sudo bash`.
echo "" local result=$(runCrowdsec install)
echo "---- $menu_number. Installing the CrowdSec firewall bouncer." checkSuccess "Installing CrowdSec agent + firewall bouncer + baseline collections"
echo ""
local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q crowdsec-firewall-bouncer-nftables </dev/null 2>&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"
((menu_number++)) ((menu_number++))
echo "" echo ""
@ -106,14 +68,14 @@ installCrowdsecHost()
# unregistering. Idempotent on either branch. # unregistering. Idempotent on either branch.
local community_blocklist="${CFG_CROWDSEC_COMMUNITY_BLOCKLIST:-true}" local community_blocklist="${CFG_CROWDSEC_COMMUNITY_BLOCKLIST:-true}"
if [[ "$community_blocklist" == "true" ]]; then 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." isNotice "Community blocklist already registered."
else else
local result=$(runSystem cscli capi register 2>&1) local result=$(runCrowdsec capi register 2>&1)
checkSuccess "Registered with CrowdSec Central API (community blocklist)" checkSuccess "Registered with CrowdSec Central API (community blocklist)"
fi fi
else 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)" checkSuccess "Unregistered from CrowdSec Central API (community blocklist disabled)"
fi fi
@ -122,26 +84,26 @@ installCrowdsecHost()
echo "---- $menu_number. SaaS Console enrollment toggle." echo "---- $menu_number. SaaS Console enrollment toggle."
echo "" echo ""
# cscli console enroll <token> registers this agent with the hosted # `console enroll <token>` registers this agent with the hosted
# dashboard at app.crowdsec.net. Idempotent: if already enrolled, # dashboard at app.crowdsec.net. Idempotent: if already enrolled,
# skip. If toggled off, disenroll. Quietly skipped when the flag # skip. If toggled off, disenroll. Quietly skipped when the flag
# is on but the token field is empty (user hasn't pasted one yet). # is on but the token field is empty (user hasn't pasted one yet).
local console_enroll="${CFG_CROWDSEC_CONSOLE_ENROLL:-false}" local console_enroll="${CFG_CROWDSEC_CONSOLE_ENROLL:-false}"
local console_token="${CFG_CROWDSEC_CONSOLE_TOKEN:-}" local console_token="${CFG_CROWDSEC_CONSOLE_TOKEN:-}"
local enrolled=false 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 [[ "$console_enroll" == "true" ]]; then
if [[ -z "$console_token" ]]; 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." isNotice "Console enrollment ON but CFG_CROWDSEC_CONSOLE_TOKEN is empty — paste your token from app.crowdsec.net to complete."
elif [[ "$enrolled" == true ]]; then elif [[ "$enrolled" == true ]]; then
isNotice "Already enrolled with the SaaS console — skipping." isNotice "Already enrolled with the SaaS console — skipping."
else 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" checkSuccess "Enrolled with app.crowdsec.net SaaS console"
fi fi
else else
if [[ "$enrolled" == true ]]; then 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" checkSuccess "Disenrolled from app.crowdsec.net SaaS console"
else else
isNotice "SaaS console enrollment disabled — skipping." isNotice "SaaS console enrollment disabled — skipping."
@ -157,14 +119,14 @@ installCrowdsecHost()
# via host.docker.internal:host-gateway. The bouncer API key is # via host.docker.internal:host-gateway. The bouncer API key is
# required (HTTP 401 without it), so internet exposure is gated. # required (HTTP 401 without it), so internet exposure is gated.
# External access on 8080 should still be blocked at UFW. # External access on 8080 should still be blocked at UFW.
local lapi_cfg="/etc/crowdsec/config.yaml" local bind_result
if [[ -f "$lapi_cfg" ]] && ! sudo grep -qE 'listen_uri:[[:space:]]*0\.0\.0\.0:8080' "$lapi_cfg"; then bind_result=$(runCrowdsec bind-lapi 2>&1)
sudo sed -i 's|listen_uri:.*|listen_uri: 0.0.0.0:8080|' "$lapi_cfg" if [[ "$bind_result" == "ALREADY_BOUND" ]]; then
checkSuccess "LAPI bound to 0.0.0.0:8080"
runSystem systemctl restart crowdsec
checkSuccess "CrowdSec restarted"
else
isNotice "LAPI already bound to 0.0.0.0:8080 — skipping." 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 fi
((menu_number++)) ((menu_number++))
@ -174,72 +136,62 @@ installCrowdsecHost()
# When monitoring is on, bind CrowdSec's Prometheus metrics endpoint to # 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 # 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: # from the Prometheus container). When off, rebind to localhost if a
# block. When off, rebind to localhost if a prior run opened it. # prior run opened it. The helper does the scoped edit in the
local cs_cfg="/etc/crowdsec/config.yaml" # prometheus: block.
local mon_enabled="${CFG_CROWDSEC_MONITORING:-false}" local mon_enabled="${CFG_CROWDSEC_MONITORING:-false}"
local prom_listen="${CFG_CROWDSEC_PROMETHEUS_LISTEN:-0.0.0.0:6060}" local prom_listen="${CFG_CROWDSEC_PROMETHEUS_LISTEN:-0.0.0.0:6060}"
local prom_addr="${prom_listen%%:*}" local prom_addr="${prom_listen%%:*}"
local prom_port="${prom_listen##*:}" local prom_port="${prom_listen##*:}"
if [[ "$mon_enabled" == "true" && -f "$cs_cfg" ]]; then if [[ "$mon_enabled" == "true" ]]; then
if ! sudo grep -qE "listen_addr:[[:space:]]*${prom_addr}" "$cs_cfg"; then local result=$(runCrowdsec prometheus on "$prom_addr" "$prom_port")
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}" checkSuccess "CrowdSec metrics endpoint bound to ${prom_listen}"
runSystem systemctl restart crowdsec runCrowdsec services restart
checkSuccess "CrowdSec restarted" checkSuccess "CrowdSec restarted"
else else
isNotice "CrowdSec metrics already bound to ${prom_addr} — skipping." local result=$(runCrowdsec prometheus off)
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)" checkSuccess "CrowdSec metrics endpoint rebound to 127.0.0.1 (monitoring off)"
runSystem systemctl restart crowdsec runCrowdsec services restart
checkSuccess "CrowdSec restarted" checkSuccess "CrowdSec restarted"
else
isNotice "Monitoring off — CrowdSec metrics endpoint left at its default."
fi fi
# Generate a dedicated bouncer key for Traefik (idempotent: skip if ((menu_number++))
# already registered). Two sinks for the value: 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 # 1) /etc/crowdsec/traefik_bouncer.key — raw key, bind-mounted into
# the Traefik container read-only; the plugin reads it via # the Traefik container read-only; the plugin reads it via
# crowdsecLapiKeyFile. /etc/crowdsec/ is outside the framework's # crowdsecLapiKeyFile. /etc/crowdsec/ is outside the framework's
# sourceScanFiles sweep so a bare key file is safe here. # 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. # line, sourced by the framework and visible on the config page.
# Editing the CFG var manually does not re-register the bouncer # Editing the CFG var manually does not re-register the bouncer
# (use the rotate Tools action for that); this is a visibility # (use the rotate Tools action for that); this is a visibility
# surface, not the auth source of truth. # surface, not the auth source of truth.
local key_file="/etc/crowdsec/traefik_bouncer.key" # The helper handles cscli + tee + chown + chmod atomically.
local cfg_file="${configs_dir}security/security_crowdsec" local init_result
init_result=$(runCrowdsec bouncer-traefik-init 2>&1)
if ! runSystem cscli bouncers list -o raw 2>/dev/null | grep -q '^traefik-bouncer'; then if [[ "$init_result" == "EXISTS" ]]; then
local bouncer_key isNotice "Bouncer 'traefik-bouncer' already registered — leaving existing key file untouched at /etc/crowdsec/traefik_bouncer.key."
bouncer_key=$(runSystem cscli bouncers add traefik-bouncer -o raw 2>&1 | tail -1) elif [[ "$init_result" == GENERATED:* ]]; then
if [[ -n "$bouncer_key" && "$bouncer_key" != *"error"* ]]; then local bouncer_key="${init_result#GENERATED:}"
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" checkSuccess "Traefik bouncer API key generated"
# Write the key into the live config file so it's visible / # Mirror the key into the live config file so it's visible /
# editable via the framework's config page like any other # editable via the framework's config page like any other CFG_*
# CFG_* setting. # setting. configs/ is manager-owned, so runInstallOp suffices.
local cfg_file="${configs_dir}security/security_crowdsec"
if [[ -f "$cfg_file" ]]; then if [[ -f "$cfg_file" ]]; then
sudo sed -i "s|^CFG_CROWDSEC_TRAEFIK_LAPI_KEY=.*|CFG_CROWDSEC_TRAEFIK_LAPI_KEY=${bouncer_key}|" "$cfg_file" 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" checkSuccess "Key mirrored to CFG_CROWDSEC_TRAEFIK_LAPI_KEY"
else else
isNotice "Live config not present yet — key applied on next install." isNotice "Live config not present yet — key applied on next install."
fi fi
else else
isNotice "Failed to generate bouncer key — Traefik integration won't authenticate. Re-run installCrowdsecHost to retry." isNotice "Failed to generate bouncer key: $init_result"
fi isNotice "Traefik integration won't authenticate. Re-run installCrowdsecHost to retry."
else
isNotice "Bouncer 'traefik-bouncer' already registered — leaving existing key file untouched at $key_file."
fi fi
((menu_number++)) ((menu_number++))
@ -291,11 +243,8 @@ installCrowdsecHost()
echo "---- $menu_number. Re-enabling CrowdSec services." echo "---- $menu_number. Re-enabling CrowdSec services."
echo "" echo ""
local result=$(runSystem systemctl enable --now crowdsec) local result=$(runCrowdsec services enable)
checkSuccess "Enabling CrowdSec agent" checkSuccess "Enabling CrowdSec agent + firewall bouncer"
local result=$(runSystem systemctl enable --now crowdsec-firewall-bouncer)
checkSuccess "Enabling CrowdSec firewall bouncer"
isSuccessful "CrowdSec services re-enabled." isSuccessful "CrowdSec services re-enabled."
menu_number=0 menu_number=0
@ -312,11 +261,8 @@ installCrowdsecHost()
echo "---- $menu_number. Stopping and disabling CrowdSec services." echo "---- $menu_number. Stopping and disabling CrowdSec services."
echo "" echo ""
local result=$(runSystem systemctl disable --now crowdsec-firewall-bouncer 2>&1) local result=$(runCrowdsec services disable)
checkSuccess "Disabling CrowdSec firewall bouncer" checkSuccess "Disabling CrowdSec agent + firewall bouncer"
local result=$(runSystem systemctl disable --now crowdsec 2>&1)
checkSuccess "Disabling CrowdSec agent"
isSuccessful "CrowdSec disabled. Package remains installed — set CFG_CROWDSEC_ENABLED=true to re-enable, or uninstall via the Tools tab." isSuccessful "CrowdSec disabled. Package remains installed — set CFG_CROWDSEC_ENABLED=true to re-enable, or uninstall via the Tools tab."
menu_number=0 menu_number=0

View File

@ -892,7 +892,8 @@ Cmnd_Alias LP_HELPERS = ${lp_lib_dir}/libreportal-ownership, \\
${lp_lib_dir}/libreportal-socket, \\ ${lp_lib_dir}/libreportal-socket, \\
${lp_lib_dir}/libreportal-svc, \\ ${lp_lib_dir}/libreportal-svc, \\
${lp_lib_dir}/libreportal-bininstall, \\ ${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, \\ Cmnd_Alias LP_SYSTEM = /usr/bin/systemctl, /usr/sbin/ufw, /usr/local/bin/ufw-docker, \\
/usr/sbin/nft, /usr/sbin/sysctl, /sbin/sysctl, \\ /usr/sbin/nft, /usr/sbin/sysctl, /sbin/sysctl, \\
/usr/bin/loginctl, /usr/sbin/service /usr/bin/loginctl, /usr/sbin/service
@ -932,7 +933,7 @@ initRootHelpers()
# sudo's (the trust boundary the scoped sudoers relies on). # sudo's (the trust boundary the scoped sudoers relies on).
sudo install -d -m 0755 -o root -g root "$lp_lib_dir" sudo install -d -m 0755 -o root -g root "$lp_lib_dir"
local helper helper_src helper_dst helper_tmp 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_src="$script_dir/scripts/system/$helper"
helper_dst="$lp_lib_dir/$helper" helper_dst="$lp_lib_dir/$helper"
if [[ ! -f "$helper_src" ]]; then if [[ ! -f "$helper_src" ]]; then

View File

@ -136,10 +136,19 @@ runSvc() { _runRootHelper libreportal-svc "$@"; }
runBinInstall() { _runRootHelper libreportal-bininstall "$@"; } runBinInstall() { _runRootHelper libreportal-bininstall "$@"; }
# App config-file rewrites owned by in-container uids / root /etc: # App config-file rewrites owned by in-container uids / root /etc:
# {adguard-auth <user> <bcrypt>|crowdsec-priority| # {adguard-auth <user> <bcrypt>|owncloud-config <public> <host> <ip> <public_ip>|
# owncloud-config <public> <host> <ip> <public_ip>} # wireguard-ip-forward}
runAppCfg() { _runRootHelper libreportal-appcfg "$@"; } 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 <enable|disable|restart>|capi <register|unregister|status>
# |console <enroll <token>|disenroll|status>|bouncer-traefik-init|
# bouncer-priority|bind-lapi|prometheus <on <addr> <port>|off>|touch-host-logs}
runCrowdsec() { _runRootHelper libreportal-crowdsec "$@"; }
# Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc # Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc
# edits). Needs real root in both modes; funnelled through one place so it can # edits). Needs real root in both modes; funnelled through one place so it can
# later be confined to a scoped sudoers allowlist. # later be confined to a scoped sudoers allowlist.

View File

@ -720,6 +720,7 @@ declare -gA LP_FN_MAP=(
[runAsManager]="docker/command/run_privileged.sh" [runAsManager]="docker/command/run_privileged.sh"
[runBackupOp]="docker/command/run_privileged.sh" [runBackupOp]="docker/command/run_privileged.sh"
[runBinInstall]="docker/command/run_privileged.sh" [runBinInstall]="docker/command/run_privileged.sh"
[runCrowdsec]="docker/command/run_privileged.sh"
[runFileOp]="docker/command/run_privileged.sh" [runFileOp]="docker/command/run_privileged.sh"
[runFileWrite]="docker/command/run_privileged.sh" [runFileWrite]="docker/command/run_privileged.sh"
[runInstallOp]="docker/command/run_privileged.sh" [runInstallOp]="docker/command/run_privileged.sh"
@ -1582,6 +1583,7 @@ declare -gA LP_FN_ROOT=(
[runAsManager]="scripts" [runAsManager]="scripts"
[runBackupOp]="scripts" [runBackupOp]="scripts"
[runBinInstall]="scripts" [runBinInstall]="scripts"
[runCrowdsec]="scripts"
[runFileOp]="scripts" [runFileOp]="scripts"
[runFileWrite]="scripts" [runFileWrite]="scripts"
[runInstallOp]="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 "$@"; } runAsManager() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runAsManager "$@"; }
runBackupOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runBackupOp "$@"; } runBackupOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runBackupOp "$@"; }
runBinInstall() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runBinInstall "$@"; } 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 "$@"; } runFileOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runFileOp "$@"; }
runFileWrite() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runFileWrite "$@"; } runFileWrite() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runFileWrite "$@"; }
runInstallOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runInstallOp "$@"; } runInstallOp() { source "${install_scripts_dir}docker/command/run_privileged.sh"; runInstallOp "$@"; }

View File

@ -68,22 +68,6 @@ EOF
sysctl --system >/dev/null 2>&1 || sysctl -p "$dropin" >/dev/null 2>&1 || true 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: normalise trusted_domains + overwrite.cli.url in config.php ------
owncloud_config() { owncloud_config() {
local public="$1" host_setup="$2" ip_setup="$3" public_ip="$4" local public="$1" host_setup="$2" ip_setup="$3" public_ip="$4"
@ -143,8 +127,7 @@ EOL
action="${1:-}"; shift 2>/dev/null || true action="${1:-}"; shift 2>/dev/null || true
case "$action" in case "$action" in
adguard-auth) adguard_auth "${1:-}" "${2:-}" ;; adguard-auth) adguard_auth "${1:-}" "${2:-}" ;;
crowdsec-priority) crowdsec_priority ;;
owncloud-config) owncloud_config "${1:-}" "${2:-}" "${3:-}" "${4:-}" ;; owncloud-config) owncloud_config "${1:-}" "${2:-}" "${3:-}" "${4:-}" ;;
wireguard-ip-forward) wireguard_ip_forward ;; wireguard-ip-forward) wireguard_ip_forward ;;
*) echo "usage: libreportal-appcfg {adguard-auth <user> <bcrypt>|crowdsec-priority|owncloud-config <public> <host> <ip> <public_ip>|wireguard-ip-forward}" >&2; exit 2 ;; *) echo "usage: libreportal-appcfg {adguard-auth <user> <bcrypt>|owncloud-config <public> <host> <ip> <public_ip>|wireguard-ip-forward}" >&2; exit 2 ;;
esac esac

View File

@ -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 <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
}
# --- 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 <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-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 <enable|disable|restart>|capi <register|unregister|status>|console <enroll <token>|disenroll|status>|bouncer-traefik-init|bouncer-priority|bind-lapi|prometheus <on <addr> <port>|off>|touch-host-logs}" >&2
exit 2
;;
esac