#!/bin/bash

## This script is used to compare the iptables and ferm outputs
## MON-1796
## MON-2390 (25.08.2026 vo): req1 iptables return-code is the main condition; req2 a failed
##   command's output is logged but NOT used for comparison; req3 all iptables commands fail
##   -> RESULT_FAIL (distinct trigger-failure state); req4 skip the whole check on Xen guests
##   (RESULT_SKIP); req5 self-confirm on NOK to suppress ferm-reload race (SEC-10633), OFF by
##   default (RECHECK_ON_NOK), runtime-togglable via <files_path>recheck.env. All additive.
## MON-2390 (24.09.2026 vo): v1.3.3 additive normalization fixes from the fleet rollout:
##   (a) exclude injected loopback anti-spoof rules '-A PREROUTING -d 127.0.0.x ! -i lo -j DROP'
##       (raw/PREROUTING, not ferm-managed) from BOTH sides -> kills the standing false NOK on
##       ~11 hosts; (b) reconcile protocol name<->number (vrrp<->112, esp<->50, ...) via
##       /etc/protocols and the conntrack-state module alias (-m state --state <-> -m conntrack
##       --ctstate). All symmetric (applied to both sides) so they can only remove a false diff,
##       never create one. (c) distinct "ferm declared but NOT enforced" verdict when >=90% of the
##       declared ferm rules are absent from live (root cause named instead of a wall of rules;
##       stays RESULT_NOK). Signal is the comparison itself, not systemctl (ferm oneshot -> is-active
##       unreliable).

# Set low priority for script execution
priority=10  # 19=lowest, -20=highest
pid=$$ # Get the process ID (PID) of the current script
renice $priority $pid > /dev/null 2>&1  # Adjust the priority of the script

# --- MON-2390: resource guards (best-effort; never abort the run if a tool is missing) ---
command -v ionice >/dev/null 2>&1 && ionice -c3 -p $$ >/dev/null 2>&1   # idle I/O class (lowest)
ulimit -v 4194304 2>/dev/null       # address-space cap ~4GB: catches a true runaway, headroom for perl/ferm + big rulesets
CMD_TIMEOUT=15                       # max wall-seconds for one external dump/render (a single wedged call; < 30 wall)
HAVE_TIMEOUT=; command -v timeout >/dev/null 2>&1 && HAVE_TIMEOUT=1
# Run a command under a wall-clock timeout when coreutils 'timeout' exists; else run as-is.
# A timed-out external call returns non-zero -> handled exactly like a failed command.
bounded() { local secs="$1"; shift; if [ -n "$HAVE_TIMEOUT" ]; then timeout -k 2 "$secs" "$@"; else "$@"; fi; }
# -----------------------------------------------------------------------------------------

# req5: resolve our own absolute path BEFORE any cd, for the self-invoke re-check
SCRIPT_PATH="$(readlink -f "$0")"

# req5: when invoked as a silent re-check child, never emit anything to Zabbix
if [ -n "$FERM_RECHECK" ]; then
    zabbix_sender () { :; }
fi

# --------- Settings -------------------
dry_run=false # <-- DRY RUN (true/false)
#files_path='/home/vo/ferm-iptables-diff/'
files_path='/opt/zabbix/ferm-iptables-diff/'
now=$(date --utc '+%Y-%m-%d %H:%M:%S %Z')
main_log="main.log"
script_err_last='script_err_last.tmp'
# --------------------------------------

# --- req5 (MON-2390): self-confirm on NOK to suppress ferm-reload race false positives ---
# Ships OFF by default (byte-identical to <=1.2.5). Enable per-host WITHOUT a rebuild by
# creating the override file, e.g.:  echo 'RECHECK_ON_NOK=true' > <files_path>recheck.env
RECHECK_ON_NOK=false     # false => immediate NOK, no re-check (old behaviour)
RECHECK_ATTEMPTS=1       # silent re-checks when first pass is NOK (budget: < 30s Timeout)
RECHECK_DELAY=5          # seconds to wait before a re-check
# Runtime override WITHOUT sourcing (the dir is zabbix-writable but we run as root, so
# 'source' would be a root code-exec vector). Parse ONLY whitelisted key=value, strict.
_ov="${files_path}recheck.env"
if [ -r "$_ov" ]; then
    _ovc=$(tr -d "\r" < "$_ov")   # tolerate CRLF (Windows-edited override)
    _v=$(printf "%s\n" "$_ovc" | grep -E "^RECHECK_ON_NOK=(true|false)$" | tail -1); [ -n "$_v" ] && RECHECK_ON_NOK="${_v#*=}"
    _v=$(printf "%s\n" "$_ovc" | grep -E "^RECHECK_ATTEMPTS=[0-9]+$"    | tail -1); [ -n "$_v" ] && RECHECK_ATTEMPTS="${_v#*=}"
    _v=$(printf "%s\n" "$_ovc" | grep -E "^RECHECK_DELAY=[0-9]+$"       | tail -1); [ -n "$_v" ] && RECHECK_DELAY="${_v#*=}"
fi
# Clamp to safe bounds so a bad/hostile override cannot overrun the agent Timeout=30
# (worst case = ATTEMPTS*DELAY sleeps + (ATTEMPTS+1) passes; keep well under 30s)
{ [ "$RECHECK_ATTEMPTS" -ge 1 ] && [ "$RECHECK_ATTEMPTS" -le 2 ]; } 2>/dev/null || RECHECK_ATTEMPTS=1
{ [ "$RECHECK_DELAY" -ge 1 ] && [ "$RECHECK_DELAY" -le 5 ]; } 2>/dev/null || RECHECK_DELAY=5
# ------------------------------------------------------------------------------------------

# Create the directory if it doesn't exist
mkdir -p "$files_path"
cd "$files_path"
echo -e "\n      ### Start script $now ###" 
echo -e "\n ------------------------------------- Start script $now -------------------------------------" >> $files_path$main_log
if [[ $dry_run == true ]]; then
    echo "----------- Dry run mode. No metrics sent to Zabbix -----------------" 
    echo -e "\n Dry run mode. No metrics sent to Zabbix." >> $files_path$main_log
fi

check_err_fun () { if [ $? -ne 0 ]; then
                echo "Error" > "$file_path$script_err_last"
                echo "Error output was detected while running a script: $(cat $file_path$script_err_last)"
                echo -e "\n Error output was detected while running a script: $(cat $file_path$script_err_last)" >> $files_path$main_log
                echo "  $now" >> $file_path$main_log
  				cat $file_path$script_err_last >> $file_path$main_log
  				: > $file_path$script_err_last
		      fi }

#Check and clean log file
truncate_log_file() {
  local logfile="$1"
  local line_count=$(wc -l < "$logfile")

  if [ $line_count -gt 11000 ]; then
    echo -e "\n     ### Log file has more than 11000 lines. Truncating 1000 older lines... ###" >> "$logfile"
    sed -i '1,1000d' "$logfile"
    echo "Truncation complete: $(date)" >> "$logfile"
fi
}

truncate_log_file "$files_path$main_log"

# --- req4 (MON-2390): skip the whole ferm/iptables check on Xen GUESTS (DomU) ---
# Xen guests have a distinct network setup that yields spurious ferm-vs-iptables diffs.
# Must NOT skip a Xen HOST (Dom0) - that would drop monitoring on a real host. So:
#   guest = (kernel marker /proc/xen or /sys/hypervisor/type=xen) AND NOT Dom0
#           (Dom0 is identified by 'control_d' in /proc/xen/capabilities)
#        OR a NIC bound to the DomU-only 'xen-netfront' driver. This driver fallback
#           is a rarely-hit safety net: a fleet audit (2026-09-16) found 0 Xen guests
#           WITHOUT /proc/xen, so in practice the kernel marker above catches them all.
#           'vif' is intentionally NOT matched here: a Dom0 exposes host-side 'vif'
#           backend interfaces, so matching that driver would wrongly skip a real host.
is_xen=false
if [ -e /proc/xen ] || { [ -r /sys/hypervisor/type ] && grep -qi xen /sys/hypervisor/type; }; then
    # kernel says Xen; treat as guest UNLESS this is Dom0 (control_d capability)
    if ! { [ -r /proc/xen/capabilities ] && grep -q control_d /proc/xen/capabilities; }; then
        is_xen=true
    fi
fi
if [ "$is_xen" = false ]; then
    for _drvlink in /sys/class/net/*/device/driver; do
        [ -e "$_drvlink" ] || continue
        case "$(basename "$(readlink -f "$_drvlink" 2>/dev/null)" 2>/dev/null)" in
            xen-netfront) is_xen=true; break ;;
        esac
    done
fi
if [ "$is_xen" = true ]; then
    echo "req4: Xen guest detected -> skipping ferm/iptables check" | tee -a "$files_path$main_log"
    if [[ $dry_run == false ]]; then
        zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "RESULT_SKIP: xen guest (check skipped)"
    fi
    exit 0
fi
# -------------------------------------------------------------------------------


# --- Check IPv6 Status ---
cmdline=$(cat /proc/cmdline) # Read kernel command line parameters
IPV6_DISABLED_FLAG=false     # Initialize flag

if [[ "$cmdline" == *"ipv6.disable=1"* ]]; then
    echo "ipv6.disable=1"
    IPV6_DISABLED_FLAG=true
    echo "ipv6 disabled cmdline: ($cmdline)" | tee -a "$files_path$main_log"
else
    echo "ipv6 not disabled cmdline: ($cmdline)"
    echo "ipv6 not disabled cmdline: ($cmdline)" >> "$files_path$main_log"
    zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables_ipv6_check.trap -o "$(echo "Not disabled ($cmdline)")"
fi

# ferm data
# clear ferm_combined.conf tmp file
: > ./ferm_combined.conf

#Check ferm conf exists
if [ -d /etc/ferm/ferm.d ]; then
    if [[ $IPV6_DISABLED_FLAG == true ]]; then
        for f in /etc/ferm/ferm.d/*.conf; do 
        [[ "$f" != *-6.conf ]] && cat "$f" 
        done 1> ferm_combined.conf 2> script_err_last.tmp
        check_err_fun
    else
        sh -c 'cat /etc/ferm/ferm.d/*.conf 1> ferm_combined.conf 2> script_err_last.tmp'
        check_err_fun
    fi

    if [ -s ferm_combined.conf ]; then
        bounded "$CMD_TIMEOUT" ferm --remote ./ferm_combined.conf > ferm_comb.txt
        ferm_rc=$?
        check_err_fun
        # guard (MON-2390): a failed/timed-out/empty ferm render must NOT be compared - an
        # empty ferm side would read as a false "excess in IPTABLES" NOK. Report as broken.
        if [ "$ferm_rc" -ne 0 ] || [ ! -s ferm_comb.txt ]; then
            echo "ferm --remote failed (rc=$ferm_rc) or produced no output -> RESULT_FAIL" | tee -a "$files_path$main_log"
            if [[ $dry_run == false ]]; then
                zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "RESULT_FAIL: ferm render failed (check itself broken)"
            fi
            exit 0
        fi
    else
        echo "ferm_combined.conf is empty! Exit."
        echo "ferm_combined.conf is empty! Exit." >> $files_path$main_log
        zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "$(echo "Warning: ferm_combined.conf is empty!")"
        exit 0
    fi    
else
    echo "Warning: ferm config is empty! Exit."
    echo "Warning: ferm config is empty! Exit." >> $files_path$main_log
    zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "Warning: ferm dir not found!"
    exit 0
fi

# dump_ruleset: capture the live ruleset from the netfilter backends (MON-2390).
# The backend SPLIT only happens on DOCKER hosts: iptables-legacy and iptables-nft are two
# distinct, simultaneously-active netfilter subsystems (x_tables vs nf_tables). ferm owns one
# (legacy) while Docker owns the other (nft). On such a host "stop at the first non-empty
# backend" picked the docker-only nft dump and reported every ferm rule as a false "Excess in
# FERM" (aws-arm1/prodsup-utils1/xntps...; INF-41898). So the behaviour is Docker-conditional:
#   - Docker host  (MERGE_BACKENDS=true)  -> MERGE every rc=0 backend that has rules (union);
#     safe because the check is a SET comparison (downstream `grep ^-A | sort -u` de-dups an
#     `iptables-save` alias of a backend, and header/COMMIT lines are ignored).
#   - non-Docker host                     -> keep the proven <=1.3.x behaviour: use the FIRST
#     backend that has rules (rules live in one backend, no split -> zero regression risk).
# Return: 0 = got rules; 1 = every backend ran (rc=0) but all empty; 2 = every backend failed
# (rc!=0) -> req3 FAIL. req1 (rc is the gate) and req2 (failed output logged, not used) intact.
dump_ruleset() {
    local label="$1"; local out="$2"; shift 2
    local cmd rc any_ran=0 any_rules=0
    : > "$out"
    for cmd in "$@"; do
        : > ./dump_try.tmp; : > ./dump_err.tmp
        bounded "$CMD_TIMEOUT" sudo $cmd > ./dump_try.tmp 2>./dump_err.tmp   # $cmd single-word (SC2086 intentional); wall-bounded
        rc=$?
        if [ $rc -eq 0 ]; then
            any_ran=1
            if [ -s ./dump_try.tmp ]; then
                cat ./dump_try.tmp >> "$out"
                any_rules=1
                if [ "$MERGE_BACKENDS" = true ]; then
                    echo "[$label] merged rules via '$cmd' (rc=0, Docker host -> union of backends)" | tee -a "$files_path$main_log"
                else
                    # non-Docker host: rules live in a single backend -> stop at the first one
                    # that has rules (proven <=1.3.x behaviour, no regression).
                    echo "[$label] dumped via '$cmd' (rc=0, single backend)" | tee -a "$files_path$main_log"
                    rm -f ./dump_try.tmp ./dump_err.tmp
                    return 0
                fi
            else
                echo "[$label] '$cmd' rc=0 but no rules in this backend" | tee -a "$files_path$main_log"
                cat ./dump_err.tmp >> "$files_path$main_log"
            fi
        else
            # req2: log the FAILED command's output+stderr, but DO NOT use it for comparison
            echo "[$label] '$cmd' FAILED (rc=$rc) - output logged, NOT used for comparison" | tee -a "$files_path$main_log"
            echo "--- [$label] '$cmd' stdout (UNUSED) ---" >> "$files_path$main_log"; cat ./dump_try.tmp >> "$files_path$main_log"
            echo "--- [$label] '$cmd' stderr ---" >> "$files_path$main_log"; cat ./dump_err.tmp >> "$files_path$main_log"
        fi
    done
    rm -f ./dump_try.tmp ./dump_err.tmp
    if   [ "$any_rules" -eq 1 ]; then return 0   # merged a non-empty union of backends
    elif [ "$any_ran"   -eq 1 ]; then return 1   # every backend ran but produced no rules
    else return 2; fi                            # every backend failed (rc!=0) -> req3 FAIL
}

# IPTABLES
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" # Set temporary PATH

# Detect Docker (MON-2390): only a Docker host splits ferm (legacy) and Docker (nft) across two
# netfilter backends, which is the ONLY case that needs a backend merge. Everywhere else rules
# live in one backend -> keep the single-backend dump. Detection is read-only and needs no sudo;
# err toward true (a false positive just means "merge an empty nft backend" = harmless no-op).
MERGE_BACKENDS=false
if command -v docker >/dev/null 2>&1 || command -v dockerd >/dev/null 2>&1 \
   || [ -S /var/run/docker.sock ] || [ -e /sys/class/net/docker0 ]; then
    MERGE_BACKENDS=true
    echo "Docker detected -> merging netfilter backends for the iptables dump" | tee -a "$files_path$main_log"
fi

# --- Dump IPv4 Rules (req1: return code is the MAIN condition) ---
echo "--- Dumping IPv4 Rules ---" | tee -a "$files_path$main_log"
dump_ruleset "IPv4" ./iptables_save_full.txt iptables-nft-save iptables-legacy-save iptables-save
ipt_rc=$?
if [ "$ipt_rc" -eq 2 ]; then
    # req3: every IPv4 iptables command FAILED (rc!=0) -> distinct trigger-failure state
    echo "req3: ALL IPv4 iptables dump commands failed (rc!=0) -> RESULT_FAIL" | tee -a "$files_path$main_log"
    if [[ $dry_run == false ]]; then
        zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "RESULT_FAIL: all iptables dump commands failed (check itself broken)"
    fi
    exit 0
elif [ "$ipt_rc" -eq 1 ]; then
    # all backends ran OK but produced no rules -> preserve <=1.2.5 empty-warning behaviour
    echo "iptables_save_full.txt is empty (no rules on any backend)! Exit." | tee -a "$files_path$main_log"
    if [[ $dry_run == false ]]; then
        zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "Warning: iptables_save_full.txt is empty!"
    fi
    exit 0
fi
echo -e "\n      --- IPTABLES full config (IPv4) ---" >> $files_path$main_log
cat iptables_save_full.txt >> $files_path$main_log

# --- Dump IPv6 Rules (best-effort; req1 return-code disciplined) ---
# v6-ONLY backend list: with the merge model the leading command must be ip6tables-nft-save,
# NOT iptables-nft-save (v4) - otherwise a docker host's v4 nft rules would be merged into the
# v6 dump. Healthy-path stays identical (the stray v4 rules were docker-filtered before anyway).
: > ./ip6tables_save.txt
echo "--- Dumping IPv6 Rules ---" | tee -a "$files_path$main_log"
if [ "$IPV6_DISABLED_FLAG" = false ]; then
    dump_ruleset "IPv6" ./ip6tables_save.txt ip6tables-nft-save ip6tables-legacy-save ip6tables-save \
      || echo "[IPv6] all IPv6 dump commands failed - continuing without IPv6 (best-effort)" | tee -a "$files_path$main_log"
else
    echo "IPv6 is disabled, skipping IPv6 rules dump." | tee -a "$files_path$main_log"
fi
# Add ip6tables_save.txt to full config file
cat ip6tables_save.txt >> iptables_save_full.txt

# Clean up temp files
rm -f ./iptables_error.tmp

# Filtering Docker rules and duplicate lines
# req4/MON-2390: also drop Docker's per-bridge FORWARD rules. Docker auto-creates these for
# EVERY bridge network (incl. custom-named bridges like 'keephq' that dodge the br-/docker0
# patterns above); they are never in ferm -> false NOK. Matched by their exact interface-only
# shapes and anchored, so real FORWARD policy (which carries -s/-d/--dport, e.g. vlan-gw6) is
# NOT touched. Applied to BOTH sides so the comparison stays balanced.
FWD_DOCKER='^-A FORWARD (-o [^ ]+ -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT|-i [^ ]+ ! -o [^ ]+ -j ACCEPT|-i [^ ]+ -o [^ ]+ -j ACCEPT) *$'
# v1.3.3/MON-2390 (raised to reporter 2026-09-16; 11+ hosts: n8n1/llm3/sm1/data-collector*/
# lendrail2/paperclip1/mcp-api2/recon20/cryptogw21/tcp.za2-llm2/exantech1): drop the loopback
# anti-spoof rules '-A PREROUTING -d 127.0.0.x ! -i lo ... -j DROP/REJECT' that a hardening/mesh
# layer injects into raw/PREROUTING for localhost-bound service ports. They are NEVER declared
# in ferm -> standing false NOK. Anchored to PREROUTING + a 127.0.0.x destination + DROP/REJECT
# so real ferm rules are not touched. Applied to BOTH sides to keep the comparison balanced.
LOOPBACK_DROP='^-A PREROUTING .*-d 127\.0\.0\.[0-9]+.*-j (DROP|REJECT)'
# grep -E "^-A " ferm_comb.txt | grep -v "f2b-ssh-iptables" | grep -vi "docker" > ferm_comb_cut.txt
grep -E "^-A " ferm_comb.txt | grep -v "f2b-ssh-iptables" \
 | grep -v -Ei "docker|br-[0-9a-z]+|veth[0-9a-z]+|MASQUERADE|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\." \
 | grep -vE "$FWD_DOCKER" \
 | grep -vE "$LOOPBACK_DROP" \
 > ferm_comb_cut.txt
# grep -E "^-A " iptables_save_full.txt | grep -v "f2b-ssh-iptables" | grep -vi "docker" | sort -u > iptables_save_full_cut.txt
grep -E "^-A " iptables_save_full.txt | grep -v "f2b-ssh-iptables" \
  | grep -v -Ei "docker|br-[0-9a-z]+|veth[0-9a-z]+|MASQUERADE|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\." \
  | grep -vE "$FWD_DOCKER" \
  | grep -vE "$LOOPBACK_DROP" \
  | sort -u > iptables_save_full_cut.txt
# Save cleared configs
echo -e "\n      --- FERM full config ---" >> $files_path$main_log ; cat ferm_comb.txt >> $files_path$main_log
echo -e "\n      --- IPTABLES full config ---" >> $files_path$main_log ; cat iptables_save_full.txt >> $files_path$main_log

# Check for exception items in configs
# clear combined temp file
: > ./check_comb_file.txt
cat ferm_comb_cut.txt >> check_comb_file.txt
cat iptables_save_full_cut.txt >> check_comb_file.txt
input_file="check_comb_file.txt"

# Check for exception items
words=("DOCKER" "pritunl")
warn_message=""
for word in "${words[@]}"; do
    if grep -E -q "$word" "$input_file"; then
        warn_message="Warning: [${word}] item found in config! Exit."
        break
    fi
done

if [ -n "$warn_message" ]; then
    echo "$warn_message"
    echo "$warn_message" >> "${files_path}${main_log}"
    zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "$warn_message"
    exit 0
fi 

# Extract port names and numbers from /etc/services for sed replacemnt
serv_ports=$(sed -e 's/#.*//' -e 's/\t/ /g' /etc/services | awk '$1 && $2 {split($2, port, "/"); if (!seen[$1]) {printf "s/ %s / %s /g;\n", $1, port[1]; seen[$1]=1}}')

# If extracting fails, use dummy
if [ $? -ne 0 ]; then
  echo "Error generating port mappings, using default value"
  echo -e "\nError extreacting /etc/services, using default value" >> $files_path$main_log
  zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "Error: Services ports extracting fail!"
  serv_ports="s/ ssh / 22 /g;"
fi

# Ferm modification source
ferm_map="
  s/ ssh / 22 /g;
  s/ tinc / 655 /g;
  s/ http / 80 /g;
  s/ https / 443 /g;
  s/ telnet / 23 /g;
  s/ ftp / 21 /g;
  s/ tftp / 69 /g;
  s/ silc / 706 /g;
  s/ openvpn / 1194 /g;
  s/ radius / 1812 /g;
  s/ puppet / 8140 /g;
  s/ snpp / 444 /g;
  s/ mysql / 3306 /g;
  s/ --delete/ -D/g;
  s/ --insert/ -I/g;
  s/ESTABLISHED,RELATED/RELATED,ESTABLISHED/g;
  s/ --replace/ -R/g;
  s/ --policy/ -P/g;
  s/ --rename-chain/ -E/g;
  s/ --new-chain/ -N/g;
  s/ --delete-chain/ -X/g;
  s/ --list/ -L/g;
  s/ --flush/ -F/g;
  s/ --zero/ -Z/g;
  s/ --help/ -h/g;
  s/ --protocol/ -p/g;
  s/ --source/ -s/g;
  s/ --destination/ -d/g;
  s/ --jump/ -j/g;
  s/ --goto/ -g/g;
  s/ --in-interface/ -i/g;
  s/ --out-interface/ -o/g;
  s/ --fragment/ -f/g;
  s/ --match / -m /g;
  s/ --set-counters/ -c/g;
  s/ -p tcp/ -p -m tcp/;
  s/ -p udp/ -p -m udp/;
"

# Combine ferm mapping and serv_ports
ferm_port_map="$serv_ports $ferm_map"

input_file="ferm_comb_cut.txt"
output_file="ferm_cut_format.txt"

# Replacement with sed
while IFS= read -r line; do
  line=$(echo "$line" | sed -e "$ferm_port_map")
  echo "$line"
done < "$input_file" | sort > "$output_file"

# Check files are not empty
if [[ ! -s iptables_save_full_cut.txt ]] && [[ ! -s ferm_cut_format.txt ]]; then
  echo -e "\n      --- IPTABLES preformatted data is EMPTY. Probably error in process. ---" >> $files_path$main_log
  zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "Error: Output files of ferm and iptables are empty!"
  exit 0
fi

# Prepare the data for comparison
echo -e "\n      --- IPTABLES preformatted data ---" >> $files_path$main_log ; cat iptables_save_full_cut.txt >> $files_path$main_log
echo -e "\n      --- FERM preformatted data ---" >> $files_path$main_log ; cat ferm_cut_format.txt >> $files_path$main_log

# Function to normalize rule formatting (new)
normalize_rule() {
                local line="$1"
                # Remove all quotes
                line=$(echo "$line" | sed 's/"//g' | sed "s/'//g")
                # Return normalized line
                echo "$line"
                }

# Del IP cidr
remove_cidr() {
    echo "$1" | sed 's/\/[0-9]\{1,2\}//'
}

# sort elements and del cidr
sort_and_clean_elements_in_file() {
    local input_file=$1
    local output_file=$2

    : > "$output_file"  # Clear output file content

    while IFS= read -r line; do
        # Normalize the line (remove quotes)
        line=$(normalize_rule "$line")
        
        # Split line into elements
        elements=($line)
        # del ip sub net + v1.3.3 canonicalization (both sides -> cannot add a diff, only reconcile)
        for i in "${!elements[@]}"; do
            el="${elements[i]}"
            # strip /NN CIDR suffix
            if [[ $el =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\/[0-9]+$ ]]; then
                el=$(remove_cidr "$el")
            fi
            # v1.3.3/MON-2390: conntrack-state module alias (ferm '-m state --state' vs iptables
            # '-m conntrack --ctstate') and protocol name->number (ferm '-p vrrp' vs iptables
            # '-p 112'). Common protos iptables prints by name on both sides (tcp/udp/icmp) are
            # excluded from PROTO_MAP, so they are left untouched.
            case "$el" in
                state)   el="conntrack" ;;
                --state) el="--ctstate" ;;
                *) if [ -n "${PROTO_MAP[$el]:-}" ]; then el="${PROTO_MAP[$el]}"; fi ;;
            esac
            elements[i]="$el"
        done
        # sort elements
        sorted_elements=($(printf "%s\n" "${elements[@]}" | LC_COLLATE=C sort))
        # combine elements
        printf "%s " "${sorted_elements[@]}" >> "$output_file"
        # new line for next line
        printf "\n" >> "$output_file"
    done < "$input_file"
}

# v1.3.3/MON-2390: protocol name->number map from /etc/protocols, used by the element loop above
# to reconcile 'ferm -p <name>' vs 'iptables-save -p <number>' (e.g. vrrp<->112, esp<->50). The
# common protocols iptables-save prints by NAME on both sides are skipped so they stay matched.
declare -A PROTO_MAP
# Seed the protocols iptables-save commonly prints as a NUMBER while ferm emits the NAME, so the
# fix works regardless of how complete the host's /etc/protocols is (some are minimal).
PROTO_MAP=( [vrrp]=112 [esp]=50 [ah]=51 [gre]=47 [ospf]=89 [igmp]=2 [pim]=103 [l2tp]=115 [sctp]=132 [ipencap]=4 [ipip]=94 )
if [ -r /etc/protocols ]; then
    while read -r _pname _pnum _rest; do
        case "$_pname" in ''|\#*) continue ;; esac
        case "$_pname" in tcp|udp|icmp|ipv6-icmp|icmpv6|udplite) continue ;; esac
        [[ "$_pnum" =~ ^[0-9]+$ ]] || continue
        [ -n "${PROTO_MAP[$_pname]:-}" ] || PROTO_MAP["$_pname"]="$_pnum"
    done < /etc/protocols
fi

# processing dif file 1
input_file="iptables_save_full_cut.txt"
output_file="iptables_sort1.txt"

sort_and_clean_elements_in_file "$input_file" "$output_file"

# processing dif file 1
input_file="ferm_cut_format.txt"
output_file="ferm_sort1.txt"

sort_and_clean_elements_in_file "$input_file" "$output_file"

# Compare the two files line by line and store the differences
grep -Fxv -f iptables_sort1.txt ferm_sort1.txt > ferm_dif1.txt
check_err_fun
grep -Fxv -f ferm_sort1.txt iptables_sort1.txt > iptables_dif1.txt
check_err_fun

# sort and del duplicate
unique_and_sort_elements() {
                            local line=$1
                            elements=($line)
                            # del duplicate
                            sorted_unique_elements=($(printf "%s\n" "${elements[@]}" | sort -u))
                            # combine elements
                            printf "%s " "${sorted_unique_elements[@]}"
                            }

# main function
process_file() {
    local input_file=$1
    local output_file=$2
    # clear output file
    : > "$output_file"

    while IFS= read -r line; do
        result=$(unique_and_sort_elements "$line")
        echo "$result" >> "$output_file"
    done < "$input_file"
}

# iptables processing
input_file="iptables_dif1.txt"
output_file="unique_iptables_output1.txt"
process_file "$input_file" "$output_file"
echo "Unique sorted elements of iptables_output:"
cat "$output_file"
echo -e "\n     unique_iptables_output1.txt " >> $files_path$main_log ; cat "$output_file" >> $files_path$main_log

# ferm processing
input_file="ferm_dif1.txt"
output_file="unique_ferm_output1.txt"
process_file "$input_file" "$output_file"
echo "Unique sorted elements of ferm_output:"
cat "$output_file"
echo -e "\n     unique_ferm_output1.txt " >> $files_path$main_log ; cat "$output_file" >> $files_path$main_log

# clear output file
: > final_dif.txt
: > final_dif_ferm.txt
: > final_dif_iptable.txt

# Compare with improved approach (handling quotes and other differences)
grep -Fxv -f unique_iptables_output1.txt unique_ferm_output1.txt > final_dif_ferm.txt
if [ -s final_dif_ferm.txt ]; then
    echo -e "Excess lines in FERM: " >> final_dif.txt ; cat final_dif_ferm.txt >> final_dif.txt
fi  
grep -Fxv -f unique_ferm_output1.txt unique_iptables_output1.txt > final_dif_iptable.txt
if [ -s final_dif_iptable.txt ]; then
    echo -e "Excess lines in IPTABLES: " >> final_dif.txt ; cat final_dif_iptable.txt >> final_dif.txt
fi

# Check comparison result
if [ -s final_dif.txt ]; then
    echo "Attention! Rules of iptables and ferm are different."
    cat final_dif.txt
    echo -e "\nFINAL RESULT: Different " >> $files_path$main_log ; cat final_dif.txt >> $files_path$main_log
    final_msg="RESULT_NOK: $(cat final_dif.txt)"
    nok=1
else
    echo "FINAL RESULT: Identical"
    echo -e "FINAL RESULT: Identical " >> $files_path$main_log ; cat final_dif.txt >> $files_path$main_log
    final_msg="RESULT_OK: Identical"
    nok=0
fi

# v1.3.3/MON-2390: distinguish "ferm declared but NOT enforced" from ordinary drift. Signal comes
# from the comparison itself (reliable), NOT from systemctl: ferm is frequently a oneshot unit, so
# `systemctl is-active ferm` reads "inactive" even on a perfectly healthy host -> using it as the
# gate would false-positive fleet-wide. Instead: if the host renders a real ferm ruleset yet ~none
# of it is live (>=90% of declared rules are "excess in FERM"), the firewall is effectively not
# applied. Stays RESULT_NOK (a real gap that must alert) but the trap text names the root cause and
# replaces the wall of missing rules. systemctl state is appended only as an informational hint.
if [ "$nok" -eq 1 ] && [ -z "$FERM_RECHECK" ]; then   # main run only; the recheck child discards final_msg
    _ferm_tot=$(grep -c . ferm_sort1.txt 2>/dev/null || echo 0)
    _ferm_miss=$(grep -c . final_dif_ferm.txt 2>/dev/null || echo 0)
    if [ "$_ferm_tot" -ge 3 ] && [ "$_ferm_miss" -ge "$(( _ferm_tot * 9 / 10 ))" ]; then
        _ferm_svc=""
        command -v systemctl >/dev/null 2>&1 && _ferm_svc=$(systemctl is-active ferm 2>/dev/null)
        final_msg="RESULT_NOK: ferm declares ${_ferm_tot} rules but ${_ferm_miss} are missing from live -> host firewall NOT enforced${_ferm_svc:+ (ferm.service=${_ferm_svc})}"
        echo -e "\nNOT ENFORCED: ${final_msg}" >> $files_path$main_log
    fi
fi

# req5: a silent re-check child writes its COMPUTED verdict to a marker file and exits.
# Early-abort paths above never reach here, so a MISSING marker means 'could not verify'
# (never treated as OK) -> a real NOK is never suppressed by a failed/killed re-check.
if [ -n "$FERM_RECHECK" ]; then
    if [ "$nok" -eq 0 ]; then echo OK > "${files_path}recheck_verdict.txt"; else echo NOK > "${files_path}recheck_verdict.txt"; fi
    exit $nok
fi

# req5: self-confirm. A lone NOK may be a ferm-reload race (check sampled a half-applied
# ruleset). Re-check; suppress ONLY if a re-check EXPLICITLY reports identical. An
# inconclusive or failed re-check keeps the NOK standing (fail-safe for a firewall control).
if [ "$nok" -eq 1 ] && [ "$RECHECK_ON_NOK" = true ]; then
    nok_msg="$final_msg"          # original NOK text, already expanded (immune to child clobber)
    confirmed=1
    for attempt in $(seq 1 "$RECHECK_ATTEMPTS"); do
        echo -e "\n req5: NOK on main pass, re-checking ($attempt/$RECHECK_ATTEMPTS) after ${RECHECK_DELAY}s..." >> $files_path$main_log
        sleep "$RECHECK_DELAY"
        rm -f "${files_path}recheck_verdict.txt"
        bounded 12 env FERM_RECHECK=1 bash "$SCRIPT_PATH"
        verdict=$(cat "${files_path}recheck_verdict.txt" 2>/dev/null)
        if [ "$verdict" = "OK" ]; then
            confirmed=0; break
        fi
        echo -e "\n req5: re-check verdict='${verdict:-none}' (inconclusive/NOK) - NOK stands." >> $files_path$main_log
    done
    if [ "$confirmed" -eq 0 ]; then
        echo -e "\n req5: transient mismatch SUPPRESSED after re-check (ferm-reload race)." >> $files_path$main_log
        final_msg="RESULT_OK: Identical (transient mismatch suppressed after re-check)"
    else
        echo -e "\n req5: mismatch CONFIRMED (not a transient race)." >> $files_path$main_log
        final_msg="$nok_msg"
    fi
fi

# Send a result to Zabbix 
if [[ $dry_run == false ]]; then
    zabbix_sender -c /etc/zabbix/zabbix_agent2.conf -k ferm-iptables.trap -o "$final_msg"
fi
        
echo -e "\n-------------- END --------------" >> $files_path$main_log;