#!/usr/bin/env bash
# =============================================================================
# onx-firewall-rule-add — Add a firewall rule (firewalld | ufw | nftables)
#
# Purpose:
#   Translate panel-level rule definitions into native firewall syntax. Picks
#   the backend present on the host in this preference order:
#     1) firewalld  (firewall-cmd)   — RHEL/AlmaLinux default
#     2) ufw        (ufw)            — Ubuntu/Debian default
#     3) nftables   (nft)            — bare-metal fallback
#
# Input (stdin JSON):
#   {
#     "type":             "ip" | "port" | "service",
#     "value":            "1.2.3.4"  | "1.2.3.0/24"  | "443"  | "443/tcp"  | "http",
#     "direction":        "in" | "out"                 (default "in"),
#     "action":           "accept" | "reject" | "drop" (default "drop"),
#     "zone":             "public"                     (default "public"),
#     "duration_minutes": 0                            (0 = permanent),
#     "comment":          "blocked by SOC"             (optional)
#   }
#
# Output (stdout JSON):
#   {
#     "rule_id":  "<backend-rule-token>",
#     "applied":  true,
#     "backend":  "firewalld" | "ufw" | "nftables",
#     "zone":     "public",
#     "duration_minutes": 0
#   }
#
# Production requirements:
#   - one of: firewalld | ufw | nftables installed and active
#   - at (atd) for duration_minutes auto-removal (best-effort)
#
# Deployed to: /usr/local/onoxsoft/bin/onx-firewall-rule-add
# =============================================================================

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

TYPE=$(onx_json_field      "type"             "ip")
VALUE=$(onx_json_field     "value"            "")
DIRECTION=$(onx_json_field "direction"        "in")
ACTION=$(onx_json_field    "action"           "drop")
ZONE=$(onx_json_field      "zone"             "public")
DURATION=$(onx_json_field  "duration_minutes" "0")
COMMENT=$(onx_json_field   "comment"          "")

[[ -n "${VALUE}" ]] || onx_die 1 "value is required"

# ── Validate inputs ──────────────────────────────────────────────────────────
case "${TYPE}" in
    ip|port|service) ;;
    *) onx_die 1 "type must be one of: ip|port|service (got '${TYPE}')" ;;
esac
case "${DIRECTION}" in
    in|out) ;;
    *) onx_die 1 "direction must be in|out (got '${DIRECTION}')" ;;
esac
case "${ACTION}" in
    accept|reject|drop) ;;
    *) onx_die 1 "action must be accept|reject|drop (got '${ACTION}')" ;;
esac
[[ "${DURATION}" =~ ^[0-9]+$ ]] || onx_die 1 "duration_minutes must be integer (got '${DURATION}')"
[[ "${ZONE}" =~ ^[a-zA-Z0-9_-]{1,32}$ ]] || onx_die 1 "invalid zone name '${ZONE}'"

# IP / CIDR sanity (IPv4 only at Phase 1)
if [[ "${TYPE}" == "ip" ]]; then
    [[ "${VALUE}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/(3[0-2]|[12]?[0-9]))?$ ]] \
        || onx_die 1 "invalid ipv4 / cidr: '${VALUE}'"
fi

# Port can be "443", "443/tcp", "20-21", "20-21/tcp"
PORT_PROTO=""
if [[ "${TYPE}" == "port" ]]; then
    if [[ "${VALUE}" =~ ^([0-9]{1,5}(-[0-9]{1,5})?)(/((tcp|udp)))?$ ]]; then
        PORT_PROTO="${BASH_REMATCH[5]:-tcp}"
        # Validate port range
        PORT_PART="${BASH_REMATCH[1]}"
        IFS='-' read -r P1 P2 <<<"${PORT_PART}"
        for P in "${P1}" "${P2:-${P1}}"; do
            (( P >= 1 && P <= 65535 )) || onx_die 1 "port out of range: '${VALUE}'"
        done
    else
        onx_die 1 "invalid port spec: '${VALUE}'"
    fi
fi

# Service: must be a known service name (firewalld-style)
if [[ "${TYPE}" == "service" ]]; then
    [[ "${VALUE}" =~ ^[a-zA-Z0-9_-]{1,32}$ ]] || onx_die 1 "invalid service name: '${VALUE}'"
fi

# ── Detect backend ───────────────────────────────────────────────────────────
BACKEND=""
if command -v firewall-cmd >/dev/null 2>&1 && \
   { systemctl is-active --quiet firewalld 2>/dev/null || [[ "${MOCK_MODE}" == "1" ]]; }; then
    BACKEND="firewalld"
elif command -v ufw >/dev/null 2>&1; then
    BACKEND="ufw"
elif command -v nft >/dev/null 2>&1; then
    BACKEND="nftables"
else
    onx_die 2 "no supported firewall backend found (firewalld|ufw|nftables)"
fi

RULE_ID=""
APPLIED="false"

# ── MOCK fast-path ───────────────────────────────────────────────────────────
if [[ "${MOCK_MODE}" == "1" ]]; then
    RULE_ID="mock-$(date +%s)-$$"
    APPLIED="true"
    onx_audit "onx-firewall" "MOCK add type=${TYPE} value=${VALUE} action=${ACTION} backend=${BACKEND}"
    jq -nc \
        --arg id "${RULE_ID}" \
        --arg backend "${BACKEND}" \
        --arg zone "${ZONE}" \
        --argjson dur "${DURATION}" \
        --argjson applied true \
        '{rule_id:$id, applied:$applied, backend:$backend, zone:$zone, duration_minutes:$dur}'
    exit 0
fi

# ── firewalld ────────────────────────────────────────────────────────────────
if [[ "${BACKEND}" == "firewalld" ]]; then
    RICH=""
    case "${TYPE}" in
        ip)
            RICH="rule family=ipv4 source address=\"${VALUE}\" ${ACTION}"
            ;;
        port)
            PORT_NUM="${VALUE%%/*}"
            RICH="rule family=ipv4 port port=\"${PORT_NUM}\" protocol=\"${PORT_PROTO}\" ${ACTION}"
            ;;
        service)
            RICH="rule family=ipv4 service name=\"${VALUE}\" ${ACTION}"
            ;;
    esac

    # Idempotency: skip if already present
    if firewall-cmd --zone="${ZONE}" --query-rich-rule="${RICH}" >/dev/null 2>&1; then
        RULE_ID="$(printf '%s' "${RICH}" | sha1sum | cut -c1-16)"
        APPLIED="true"
        onx_log "firewalld: rule already exists (idempotent): ${RICH}"
    else
        firewall-cmd --permanent --zone="${ZONE}" --add-rich-rule="${RICH}" >/dev/null \
            || onx_die 3 "firewall-cmd add-rich-rule failed"
        firewall-cmd --reload >/dev/null 2>&1 || onx_log "WARNING: firewall-cmd reload returned non-zero"
        RULE_ID="$(printf '%s' "${RICH}" | sha1sum | cut -c1-16)"
        APPLIED="true"
    fi

# ── ufw ──────────────────────────────────────────────────────────────────────
elif [[ "${BACKEND}" == "ufw" ]]; then
    UFW_ACTION="deny"
    [[ "${ACTION}" == "accept" ]] && UFW_ACTION="allow"
    [[ "${ACTION}" == "reject" ]] && UFW_ACTION="reject"

    case "${TYPE}" in
        ip)
            ufw "${UFW_ACTION}" from "${VALUE}" >/dev/null \
                || onx_die 3 "ufw ${UFW_ACTION} from ${VALUE} failed"
            ;;
        port)
            ufw "${UFW_ACTION}" "${VALUE}" >/dev/null \
                || onx_die 3 "ufw ${UFW_ACTION} ${VALUE} failed"
            ;;
        service)
            ufw "${UFW_ACTION}" "${VALUE}" >/dev/null \
                || onx_die 3 "ufw ${UFW_ACTION} ${VALUE} failed"
            ;;
    esac
    RULE_ID="$(printf '%s:%s:%s' "${TYPE}" "${VALUE}" "${UFW_ACTION}" | sha1sum | cut -c1-16)"
    APPLIED="true"
    ufw reload >/dev/null 2>&1 || true

# ── nftables ─────────────────────────────────────────────────────────────────
elif [[ "${BACKEND}" == "nftables" ]]; then
    NFT_TABLE="inet filter"
    NFT_CHAIN="input"
    [[ "${DIRECTION}" == "out" ]] && NFT_CHAIN="output"
    NFT_VERDICT="${ACTION}"
    [[ "${ACTION}" == "drop" ]]   && NFT_VERDICT="drop"
    [[ "${ACTION}" == "reject" ]] && NFT_VERDICT="reject"
    [[ "${ACTION}" == "accept" ]] && NFT_VERDICT="accept"

    case "${TYPE}" in
        ip)
            if [[ "${DIRECTION}" == "out" ]]; then
                NFT_EXPR="ip daddr ${VALUE} ${NFT_VERDICT}"
            else
                NFT_EXPR="ip saddr ${VALUE} ${NFT_VERDICT}"
            fi
            ;;
        port)
            PORT_NUM="${VALUE%%/*}"
            NFT_EXPR="${PORT_PROTO} dport ${PORT_NUM} ${NFT_VERDICT}"
            ;;
        service)
            # Minimal mapping
            case "${VALUE}" in
                http)  NFT_EXPR="tcp dport 80 ${NFT_VERDICT}"  ;;
                https) NFT_EXPR="tcp dport 443 ${NFT_VERDICT}" ;;
                ssh)   NFT_EXPR="tcp dport 22 ${NFT_VERDICT}"  ;;
                smtp)  NFT_EXPR="tcp dport 25 ${NFT_VERDICT}"  ;;
                *)     onx_die 1 "unknown service '${VALUE}' for nftables backend" ;;
            esac
            ;;
    esac
    nft add rule "${NFT_TABLE}" "${NFT_CHAIN}" ${NFT_EXPR} comment \"onx:${COMMENT:-rule}\" \
        || onx_die 3 "nft add rule failed"
    RULE_ID="$(printf '%s' "${NFT_EXPR}" | sha1sum | cut -c1-16)"
    APPLIED="true"
fi

# ── duration_minutes — schedule auto-remove via `at` ─────────────────────────
SCHEDULED="false"
if (( DURATION > 0 )) && command -v at >/dev/null 2>&1; then
    REMOVE_PAYLOAD="$(jq -nc \
        --arg id "${RULE_ID}" \
        --arg type "${TYPE}" \
        --arg value "${VALUE}" \
        --arg zone "${ZONE}" \
        --arg action "${ACTION}" \
        '{rule_id:$id,type:$type,value:$value,zone:$zone,action:$action}')"
    AT_CMD="printf '%s' '${REMOVE_PAYLOAD//\'/\\\'}' | /usr/local/onoxsoft/bin/onx-firewall-rule-remove"
    echo "${AT_CMD}" | at "now + ${DURATION} minutes" >/dev/null 2>&1 \
        && SCHEDULED="true" \
        || onx_log "WARNING: at-job scheduling failed for rule ${RULE_ID}"
fi

onx_audit "onx-firewall" "add type=${TYPE} value=${VALUE} action=${ACTION} zone=${ZONE} backend=${BACKEND} rule_id=${RULE_ID} duration=${DURATION}"

jq -nc \
    --arg id "${RULE_ID}" \
    --arg backend "${BACKEND}" \
    --arg zone "${ZONE}" \
    --argjson dur "${DURATION}" \
    --argjson applied "${APPLIED}" \
    --argjson scheduled "${SCHEDULED}" \
    '{rule_id:$id, applied:$applied, backend:$backend, zone:$zone, duration_minutes:$dur, scheduled_removal:$scheduled}'
