The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
308 lines
15 KiB
Bash
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="$(appDir 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
|
|
}
|