#!/usr/bin/env bash
# regenerate-coraza-overrides.sh — render /docker/coraza/local-overrides.conf
# and host-exceptions/<rule>.hosts from the SQLite source-of-truth tables,
# then SIGHUP coraza-spoa to pick up the changes.
#
# Wrapped in flock to serialize concurrent saves from the WHP UI.
#
# Exits 0 on success (including no-op reloads).
# Exits non-zero with a parse-error description on stderr if SIGHUP'd reload
# fails — the script also restores .bak siblings before exiting non-zero.

set -euo pipefail

SECURITY_DB="/docker/whp/sql/security.db"
CORAZA_DIR="/docker/coraza"
OVERRIDES="$CORAZA_DIR/local-overrides.conf"
PRE_OVERRIDES="$CORAZA_DIR/pre-overrides.conf"
HOSTS_DIR="$CORAZA_DIR/host-exceptions"
SIDECAR="coraza-spoa"
LOCKFILE="/run/whp-coraza-regen.lock"

exec 9>"$LOCKFILE"
if ! flock -n 9; then
    echo "regenerate-coraza-overrides.sh: another save in progress" >&2
    exit 2
fi

if ! command -v sqlite3 >/dev/null 2>&1; then
    echo "regenerate-coraza-overrides.sh: sqlite3 not in PATH" >&2
    exit 3
fi

mkdir -p "$HOSTS_DIR"

# ---------- backup current state ----------
[[ -f "$OVERRIDES" ]] && cp -p "$OVERRIDES" "$OVERRIDES.bak"
[[ -f "$PRE_OVERRIDES" ]] && cp -p "$PRE_OVERRIDES" "$PRE_OVERRIDES.bak"
rm -rf "$HOSTS_DIR.bak"
cp -rp "$HOSTS_DIR" "$HOSTS_DIR.bak"

# ---------- render local-overrides.conf (post-CRS, server-wide actions) ----------
{
    echo "# AUTOGENERATED by WHP — do not hand-edit."
    echo "# Source of truth: $SECURITY_DB coraza_rule_overrides."
    echo "# Last write: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
    echo ""
    # WAF mode directive (writes SecRuleEngine DetectionOnly if mode == detect_only).
    if [[ -f /docker/whp/settings.json ]] && command -v jq >/dev/null 2>&1; then
        mode=$(jq -r '.coraza_waf.mode // "off"' /docker/whp/settings.json)
        if [[ "$mode" == "detect_only" ]]; then
            echo "# Mode: detect_only — blanket DetectionOnly wins over rule promotions."
            echo "SecRuleEngine DetectionOnly"
            echo ""
        fi
    fi
    echo "# --- Server-wide rule actions ---"
    sqlite3 "$SECURITY_DB" -separator '|' <<'SQL'
SELECT rule_id, state, COALESCE(note,'') FROM coraza_rule_overrides ORDER BY rule_id;
SQL
} > "$OVERRIDES.tmp.raw"

awk -F'|' '
    /^[0-9]+\|/ {
        rid = $1; state = $2; note = $3;
        gsub(/[\r\n]+/, " ", note);
        if (note != "") {
            print "# " note;
        }
        if (state == "off") {
            print "SecRuleRemoveById " rid;
        } else {
            ctl = (state == "detect") ? "DetectionOnly" : "On";
            print "SecRuleUpdateActionById " rid " \"ctl:ruleEngine=" ctl "\"";
        }
        next;
    }
    { print }
' "$OVERRIDES.tmp.raw" > "$OVERRIDES.tmp"
rm -f "$OVERRIDES.tmp.raw"

# ---------- render pre-overrides.conf (pre-CRS, per-host exemptions) ----------
{
    echo "# AUTOGENERATED by WHP — do not hand-edit."
    echo "# Source of truth: $SECURITY_DB coraza_rule_host_exceptions."
    echo "# Loaded BEFORE the CRS rules so phase:1 ctl:ruleRemoveById fires"
    echo "# BEFORE the rule it's exempting would otherwise match."
    echo "# Last write: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
    echo ""
    echo "# --- Per-host exemptions ---"
    sqlite3 "$SECURITY_DB" -separator '|' "SELECT DISTINCT rule_id FROM coraza_rule_host_exceptions ORDER BY rule_id;" | \
    while IFS='|' read -r rid; do
        [[ -z "$rid" ]] && continue
        gen_id=$((990000000 + rid))
        echo "SecRule REQUEST_HEADERS:Host \"@pmFromFile /etc/coraza/host-exceptions/${rid}.hosts\" \\"
        echo "    \"id:${gen_id},phase:1,pass,nolog,ctl:ruleRemoveById=${rid}\""
    done
} > "$PRE_OVERRIDES.tmp"

# ---------- render host-exceptions/<rid>.hosts ----------
find "$HOSTS_DIR" -mindepth 1 -delete
sqlite3 "$SECURITY_DB" -separator '|' "SELECT rule_id, hostname FROM coraza_rule_host_exceptions ORDER BY rule_id, hostname;" | \
while IFS='|' read -r rid host; do
    [[ -z "$rid" || -z "$host" ]] && continue
    echo "$host" >> "$HOSTS_DIR/${rid}.hosts.tmp"
done
for tmp in "$HOSTS_DIR"/*.hosts.tmp; do
    [[ -e "$tmp" ]] || break
    mv "$tmp" "${tmp%.tmp}"
done

# Sanity floors
if ! head -3 "$OVERRIDES.tmp" | grep -q AUTOGENERATED; then
    echo "regenerate-coraza-overrides.sh: generated local-overrides missing header — refusing to install" >&2
    exit 4
fi
if ! head -3 "$PRE_OVERRIDES.tmp" | grep -q AUTOGENERATED; then
    echo "regenerate-coraza-overrides.sh: generated pre-overrides missing header — refusing to install" >&2
    exit 4
fi

mv "$OVERRIDES.tmp" "$OVERRIDES"
mv "$PRE_OVERRIDES.tmp" "$PRE_OVERRIDES"
chown 65532:65532 "$OVERRIDES" "$PRE_OVERRIDES" "$HOSTS_DIR"/* 2>/dev/null || true

# ---------- Reload + verify ----------
# coraza-spoa v0.7.1 SIGHUP reloads config but does NOT apply SecRuleRemoveById
# or other config-time directives — only action-level changes. To ensure all
# directives (including SecRuleRemoveById for "off"-state rules, and per-host
# SecRule definitions) are applied, we restart the container.
# Trade-off: ~3-5s WAF blip per save. SPOE is fail-open so HAProxy keeps serving
# requests (without WAF inspection) during the restart window. This matches the
# behavior of apply-coraza-waf-mode.sh recreates.

# Anchor log inspection to JUST BEFORE the restart so we don't pick up stale
# error lines from earlier failed attempts. Coraza-SPOA log lines use UTC with
# second precision; we anchor at 'now -1s' to avoid clock-skew gaps.
since=$(date -u -d '-1 second' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u '+%Y-%m-%dT%H:%M:%SZ')

if ! docker restart "$SIDECAR" >/dev/null; then
    echo "regenerate-coraza-overrides.sh: docker restart failed — rolling back" >&2
    [[ -f "$OVERRIDES.bak" ]] && mv "$OVERRIDES.bak" "$OVERRIDES"
    [[ -f "$PRE_OVERRIDES.bak" ]] && mv "$PRE_OVERRIDES.bak" "$PRE_OVERRIDES"
    rm -rf "$HOSTS_DIR"
    mv "$HOSTS_DIR.bak" "$HOSTS_DIR"
    docker restart "$SIDECAR" >/dev/null 2>&1 || true
    exit 5
fi

for _i in 1 2 3 4 5 6 7 8 9 10; do
    sleep 0.5
    new=$(docker logs --since "$since" "$SIDECAR" 2>&1 || true)
    # Check for fatal/parse errors in POST-anchor logs only.
    if echo "$new" | grep -qiE '"level":"error"|"level":"fatal"|failed to parse|panic'; then
        echo "regenerate-coraza-overrides.sh: reload failed — rolling back" >&2
        echo "$new" | tail -10 >&2
        # Anchor a NEW window for the rollback's restart, in case anyone
        # reads logs after this exit.
        since=$(date -u -d '-1 second' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u '+%Y-%m-%dT%H:%M:%SZ')
        [[ -f "$OVERRIDES.bak" ]] && mv "$OVERRIDES.bak" "$OVERRIDES"
        [[ -f "$PRE_OVERRIDES.bak" ]] && mv "$PRE_OVERRIDES.bak" "$PRE_OVERRIDES"
        rm -rf "$HOSTS_DIR"
        mv "$HOSTS_DIR.bak" "$HOSTS_DIR"
        docker restart "$SIDECAR" >/dev/null 2>&1 || true
        exit 5
    fi
    if echo "$new" | grep -q "Starting coraza-spoa"; then
        # Fresh boot observed AND no errors in the new window. Allow one more
        # 0.5s tick to catch a late parse-error before declaring success.
        sleep 0.5
        new=$(docker logs --since "$since" "$SIDECAR" 2>&1 || true)
        if echo "$new" | grep -qiE '"level":"error"|"level":"fatal"|failed to parse|panic'; then
            echo "regenerate-coraza-overrides.sh: reload failed — rolling back" >&2
            echo "$new" | tail -10 >&2
            [[ -f "$OVERRIDES.bak" ]] && mv "$OVERRIDES.bak" "$OVERRIDES"
            [[ -f "$PRE_OVERRIDES.bak" ]] && mv "$PRE_OVERRIDES.bak" "$PRE_OVERRIDES"
            rm -rf "$HOSTS_DIR"
            mv "$HOSTS_DIR.bak" "$HOSTS_DIR"
            docker restart "$SIDECAR" >/dev/null 2>&1 || true
            exit 5
        fi
        echo "regenerate-coraza-overrides.sh: OK"
        exit 0
    fi
done

echo "regenerate-coraza-overrides.sh: restart sent, container not seen booting in 5s window" >&2
exit 0
