#!/usr/bin/env bash
# =============================================================================
# onx-modsec-rule-write — Write a per-domain ModSecurity overlay conf and reload.
#
# Input (stdin JSON):
#   {
#     "domain":         "acme.com.tr",            -- required, RFC 1035-lite
#     "username":       "onx_acme01",             -- required (used for <Directory> path)
#     "enabled":        true,                     -- maps to SecRuleEngine On|Off
#     "global_engine":  "On",                     -- optional override: On|DetectionOnly|Off
#     "paranoia_level": 1,                        -- optional, 1..4 (OWASP CRS)
#     "excluded_rules": ["949110","981318"],      -- rule ids to disable via SecRuleRemoveById
#     "custom_rules": [                           -- optional, raw SecRule directives
#       {"id":900001,"action":"deny","pattern":"REQUEST_URI \"@contains /admin/secret\""},
#       ...
#     ]
#   }
#
# Output (stdout JSON):
#   {
#     "domain":           "acme.com.tr",
#     "conf_path":        "/etc/httpd/conf.d/modsec-onx-acme.com.tr.conf",
#     "reloaded":         true,
#     "reload_required":  false,
#     "applied":          true,
#     "rules_written":    5
#   }
#
# Behaviour:
#   - Resolves the live Apache config dir (/etc/httpd/conf.d on EL / Rocky,
#     /etc/apache2/conf-enabled on Debian).
#   - Writes the conf atomically (tmpfile + mv).
#   - Runs `apachectl configtest`; on failure restores the previous version
#     (rollback) and exits 4.
#   - On success runs `apachectl graceful` (reload). If graceful is missing,
#     falls back to `systemctl reload httpd|apache2`.
#
# Exit codes: 0=ok 1=invalid 2=preflight 3=exec 4=rolled-back 5=rollback-fail
#
# Deployed to: /usr/local/onoxsoft/bin/onx-modsec-rule-write
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=_lib/common.sh
source "${SCRIPT_DIR}/_lib/common.sh"

require_root

onx_json_input

DOMAIN=$(onx_json_field         "domain")
USERNAME=$(onx_json_field       "username")
ENABLED=$(onx_json_get_bool "${INPUT}" "enabled" "true")
GLOBAL_ENGINE=$(onx_json_field  "global_engine" "")
PARANOIA=$(onx_json_field       "paranoia_level" "1")

# ── Input validation ─────────────────────────────────────────────────────────
onx_validate_domain   "${DOMAIN}"
onx_validate_username "${USERNAME}"

# Map the enabled flag to a SecRuleEngine value if not explicitly set
if [[ -z "${GLOBAL_ENGINE}" ]]; then
    if [[ "${ENABLED}" == "true" ]]; then
        GLOBAL_ENGINE="On"
    else
        GLOBAL_ENGINE="Off"
    fi
fi
case "${GLOBAL_ENGINE}" in
    On|Off|DetectionOnly) : ;;
    *) onx_die 1 "global_engine must be On|Off|DetectionOnly (got '${GLOBAL_ENGINE}')" ;;
esac

[[ "${PARANOIA}" =~ ^[1-4]$ ]] || onx_die 1 "paranoia_level must be 1..4"

# ── Detect Apache flavour ────────────────────────────────────────────────────
APACHE_CONF_DIR=""
APACHE_SERVICE=""
if [[ -d /etc/httpd/conf.d ]]; then
    APACHE_CONF_DIR="/etc/httpd/conf.d"
    APACHE_SERVICE="httpd"
elif [[ -d /etc/apache2/conf-enabled ]]; then
    APACHE_CONF_DIR="/etc/apache2/conf-enabled"
    APACHE_SERVICE="apache2"
elif [[ "${MOCK_MODE}" == "1" ]]; then
    APACHE_CONF_DIR="$(mktemp -d -t onx-modsec.XXXXXX)"
    APACHE_SERVICE="mock"
else
    onx_die 2 "neither /etc/httpd/conf.d nor /etc/apache2/conf-enabled exists"
fi

if [[ "${MOCK_MODE}" != "1" ]]; then
    command -v apachectl >/dev/null 2>&1 || onx_die 2 "apachectl not found"
fi

# ── Compute a deterministic rule-id offset so global SecAction id collisions
# ── are rare. Anchored at 9000000 + 16-bit hash of the domain.
DOMAIN_HASH=$(printf '%s' "${DOMAIN}" | cksum | awk '{print $1 % 65536}')
SECACTION_ID=$((9000000 + DOMAIN_HASH))

CONF_PATH="${APACHE_CONF_DIR}/modsec-onx-${DOMAIN}.conf"
DOC_ROOT="/home/${USERNAME}/public_html"
TS=$(date -Iseconds)

# ── Compose the file in a tmpfile ────────────────────────────────────────────
TMP_OUT="$(mktemp -t onx-modsec.XXXXXX)"
chmod 0644 "${TMP_OUT}"

{
    printf '# ONOX-managed ModSecurity overlay for %s\n' "${DOMAIN}"
    printf '# Generated %s by onx-modsec-rule-write — do not edit manually.\n' "${TS}"
    printf '<IfModule security2_module>\n'
    printf '<Directory %s>\n' "\"${DOC_ROOT}\""
    printf '    SecRuleEngine %s\n' "${GLOBAL_ENGINE}"

    if [[ "${GLOBAL_ENGINE}" != "Off" ]]; then
        # OWASP CRS paranoia level — only meaningful when CRS is loaded globally
        printf '    SecAction "id:%d,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%s"\n' \
            "${SECACTION_ID}" "${PARANOIA}"
    fi
} > "${TMP_OUT}"

RULES_WRITTEN=0

# ── Custom rules ─────────────────────────────────────────────────────────────
# Iterate JSON array safely. Each rule is rendered as:
#   SecRule <pattern> "id:<id>,phase:2,<action>,log,msg:'onox-custom-<id>'"
if [[ "$(printf '%s' "${INPUT}" | jq -r 'has("custom_rules") and (.custom_rules | type=="array") and (.custom_rules|length>0)')" == "true" ]]; then
    while IFS= read -r rule; do
        [[ -z "${rule}" ]] && continue
        RULE_ID=$(printf '%s' "${rule}"   | jq -r '.id // empty')
        RULE_ACT=$(printf '%s' "${rule}"  | jq -r '.action // "deny"')
        RULE_PAT=$(printf '%s' "${rule}"  | jq -r '.pattern // empty')

        [[ -z "${RULE_ID}" || -z "${RULE_PAT}" ]] && continue
        [[ "${RULE_ID}" =~ ^[0-9]+$ ]] || continue
        case "${RULE_ACT}" in
            deny|allow|block|pass|drop) : ;;
            *) RULE_ACT="deny" ;;
        esac

        # `pattern` is expected to be a fully formed `VAR "@operator value"` snippet.
        printf '    SecRule %s "id:%s,phase:2,%s,log,t:none,msg:\x27onox-custom-%s\x27"\n' \
            "${RULE_PAT}" "${RULE_ID}" "${RULE_ACT}" "${RULE_ID}" >> "${TMP_OUT}"
        RULES_WRITTEN=$((RULES_WRITTEN + 1))
    done < <(printf '%s' "${INPUT}" | jq -c '.custom_rules[]?')
fi

# ── Excluded rules (SecRuleRemoveById) ───────────────────────────────────────
EXCLUDED_IDS=()
while IFS= read -r rid; do
    [[ -z "${rid}" ]] && continue
    [[ "${rid}" =~ ^[0-9]+$ ]] || continue
    EXCLUDED_IDS+=("${rid}")
done < <(onx_json_array_items "${INPUT}" "excluded_rules")

if [[ ${#EXCLUDED_IDS[@]} -gt 0 ]]; then
    # SecRuleRemoveById takes a space-separated list
    printf '    SecRuleRemoveById %s\n' "${EXCLUDED_IDS[*]}" >> "${TMP_OUT}"
    RULES_WRITTEN=$((RULES_WRITTEN + ${#EXCLUDED_IDS[@]}))
fi

{
    printf '</Directory>\n'
    printf '</IfModule>\n'
} >> "${TMP_OUT}"

# ── Move into place atomically; back up any prior version for rollback ───────
BACKUP_PATH=""
if [[ -f "${CONF_PATH}" ]]; then
    BACKUP_PATH="${CONF_PATH}.onx-bak.$$"
    cp -p "${CONF_PATH}" "${BACKUP_PATH}"
fi

mv "${TMP_OUT}" "${CONF_PATH}"
chmod 0644 "${CONF_PATH}"

# ── apachectl configtest ─────────────────────────────────────────────────────
RELOADED="false"
RELOAD_REQUIRED="true"

if [[ "${MOCK_MODE}" == "1" ]] || [[ "${APACHE_SERVICE}" == "mock" ]]; then
    RELOADED="true"
    RELOAD_REQUIRED="false"
else
    if ! apachectl configtest >/dev/null 2>&1; then
        # Roll back
        if [[ -n "${BACKUP_PATH}" ]]; then
            mv -f "${BACKUP_PATH}" "${CONF_PATH}" 2>/dev/null || true
        else
            rm -f "${CONF_PATH}" 2>/dev/null || true
        fi
        # Re-run configtest after rollback so we leave Apache happy
        apachectl configtest >/dev/null 2>&1 || onx_die 5 "configtest failed AND rollback could not restore a valid state"
        onx_die 4 "apachectl configtest rejected new ModSecurity overlay (rolled back)"
    fi

    # Configtest passed — drop backup, reload
    [[ -n "${BACKUP_PATH}" ]] && rm -f "${BACKUP_PATH}" 2>/dev/null || true

    if apachectl graceful >/dev/null 2>&1; then
        RELOADED="true"
        RELOAD_REQUIRED="false"
    elif command -v systemctl >/dev/null 2>&1; then
        if systemctl reload "${APACHE_SERVICE}" >/dev/null 2>&1; then
            RELOADED="true"
            RELOAD_REQUIRED="false"
        fi
    fi
fi

onx_audit "onx-modsec" "write domain=${DOMAIN} engine=${GLOBAL_ENGINE} rules=${RULES_WRITTEN} reloaded=${RELOADED}"

onx_json_out \
    "domain"          "${DOMAIN}" \
    "conf_path"       "${CONF_PATH}" \
    "engine"          "${GLOBAL_ENGINE}" \
    "rules_written"   "${RULES_WRITTEN}" \
    "reloaded"        "${RELOADED}" \
    "reload_required" "${RELOAD_REQUIRED}" \
    "applied"         "true"
