#!/usr/bin/env bash # EvoFirewall Linux sync agent — nft / ipset / iptables set -euo pipefail CONF_FILE=/etc/evofw/agent.conf LOG_FILE=/var/log/evofw-firewall.log STATE_DIR=/var/lib/evofw HASH_FILE="${STATE_DIR}/last_hash" POLICY_FILE="${STATE_DIR}/last_policy.json" IP_HITS_TOP=200 PORT_HITS_TOP=500 log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; } if [[ ! -f "$CONF_FILE" ]]; then log "missing $CONF_FILE" exit 1 fi # shellcheck disable=SC1090 source "$CONF_FILE" : "${EVOFW_CP_URL:?}" : "${CLIENT_TOKEN:?}" CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}" CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}" BACKEND="${KERNEL_BACKEND:-auto}" mkdir -p "$STATE_DIR" curl_policy() { local dest="$1" local code code=$(curl -sS -o "$dest" -w "%{http_code}" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Accept: application/json" \ "${EVOFW_CP_URL%/}/v1/agent/policy") || return 1 if [[ "$code" == "403" ]]; then log "pending approval" return 2 fi if [[ "$code" != "200" ]]; then log "policy HTTP $code" return 1 fi return 0 } # Do not use `if ! cmd; rc=$?` — after `!`, $? is 0, not the real status. policy_rc=0 curl_policy "$POLICY_FILE" || policy_rc=$? if [[ "$policy_rc" -eq 2 ]]; then exit 0 fi if [[ "$policy_rc" -ne 0 ]]; then exit 1 fi parse_policy() { local f="$1" PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json" if command -v jq >/dev/null 2>&1; then HASH=$(jq -r '.hash // empty' "$f") DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f") if [[ -z "$DEFAULT_ACTION" ]]; then local legacy legacy=$(jq -r '.policy_mode // "blacklist"' "$f") if [[ "$legacy" == "whitelist" ]]; then DEFAULT_ACTION=drop; else DEFAULT_ACTION=accept; fi fi mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f") mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f") jq -c '.port_rules // []' "$f" >"$PORT_RULES_FILE" 2>/dev/null || echo '[]' >"$PORT_RULES_FILE" return 0 fi if command -v python3 >/dev/null 2>&1; then eval "$(python3 - "$f" "$PORT_RULES_FILE" <<'PY' import json,sys d=json.load(open(sys.argv[1],encoding="utf-8")) print(f'HASH={d.get("hash") or ""}') da=d.get("default_action") or "" if not da: da="drop" if d.get("policy_mode")=="whitelist" else "accept" print(f'DEFAULT_ACTION={da}') print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")") print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")") open(sys.argv[2],"w",encoding="utf-8").write(json.dumps(d.get("port_rules") or [])) PY )" return 0 fi log "need jq or python3" exit 1 } HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=() PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json" parse_policy "$POLICY_FILE" # Empty deny/allow is valid — agent may have no rule sets yet. DENY=("${DENY[@]+"${DENY[@]}"}") ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}") [[ -f "$PORT_RULES_FILE" ]] || echo '[]' >"$PORT_RULES_FILE" PORT_RULES_COUNT=0 if command -v python3 >/dev/null 2>&1; then PORT_RULES_COUNT=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$PORT_RULES_FILE" 2>/dev/null || echo 0) fi log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} port_rules=$PORT_RULES_COUNT hash=$HASH" PACKETS_DROPPED=0 PACKETS_ACCEPTED=0 KERNEL_METHOD="" APPLIED=0 IP_HITS_JSON="[]" PORT_HITS_JSON="[]" HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' # 1 when deny_port_hits dynamic set is available for this apply. PORT_HITS_ENABLED=0 nft_join() { local out="" p for p in "$@"; do [[ -n "$out" ]] && out+=", " out+="$p" done printf '%s' "$out" } nft_add_chunk() { local table=$1 name=$2 setname=$3 shift 3 local joined; joined=$(nft_join "$@") nft add element "$table" "$name" "$setname" "{ ${joined} }" 2>>"$LOG_FILE" || { for p in "$@"; do nft add element "$table" "$name" "$setname" "{ $p }" 2>>"$LOG_FILE" || true; done } } # Ensure inet set exists. Prefer per-element counters; many kernels reject # `counter` on interval sets — fall back to plain interval (no per-IP hits). # Caller must delete referencing chains before recreating a set. ensure_nft_set() { local table=$1 name=$2 setname=$3 local def def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true) if [[ -n "$def" ]] && [[ "$def" == *"counter"* ]]; then return 0 fi if [[ -n "$def" ]]; then # Upgrade path: drop old set without counters (chain must already be gone). nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true fi if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"; then return 0 fi # Set may still exist if delete failed — try plain create only if missing. if nft list set "$table" "$name" "$setname" >/dev/null 2>&1; then log "nft: keep existing set $setname (no per-element counter)" return 0 fi if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; }' 2>>"$LOG_FILE"; then log "nft: set $setname without counter (interval+counter unsupported)" return 0 fi log "nft: failed to create set $setname" return 1 } collect_nft_stats() { PACKETS_DROPPED=0; PACKETS_ACCEPTED=0 local line n while IFS= read -r line; do [[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue n="${BASH_REMATCH[1]}" # Policy set hits only (ignore lo / established noise) if [[ "$line" == *@deny_v4* ]]; then PACKETS_DROPPED=$((PACKETS_DROPPED + n)) elif [[ "$line" == *@allow_v4* ]]; then PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n)) elif [[ "$line" == *" counter drop"* && "$line" != *@* ]]; then PACKETS_DROPPED=$((PACKETS_DROPPED + n)) elif [[ "$line" == *" counter accept"* && "$line" != *@* && "$line" != *established* && "$line" != *"iif \"lo\""* && "$line" != *"iif lo"* ]]; then PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n)) fi done < <(nft list chain inet evofw input 2>/dev/null || true) } # Parse nft set / ipset listing → top-N JSON [{"ip":"...","packets":N},...] build_ip_hits_json() { local text="$1" if command -v python3 >/dev/null 2>&1; then IP_HITS_JSON=$(IP_HITS_TOP="$IP_HITS_TOP" python3 -c ' import json, os, re, sys text = sys.stdin.read() top = int(os.environ.get("IP_HITS_TOP", "200")) hits = {} # nft: "1.2.3.4 counter packets 10 bytes 100" or "1.2.3.0/24 packets 5 bytes 20" for m in re.finditer(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+(?:counter\s+)?packets\s+(\d+)", text): ip, pkts = m.group(1), int(m.group(2)) if pkts > 0: hits[ip] = max(hits.get(ip, 0), pkts) # ipset list Members: "1.2.3.4 packets 10 bytes 100" for m in re.finditer(r"^([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+packets\s+(\d+)", text, re.M): ip, pkts = m.group(1), int(m.group(2)) if pkts > 0: hits[ip] = max(hits.get(ip, 0), pkts) items = [{"ip": k, "packets": v} for k, v in hits.items()] items.sort(key=lambda x: x["packets"], reverse=True) print(json.dumps(items[:top], separators=(",", ":"))) ' <<<"$text" 2>/dev/null) || IP_HITS_JSON="[]" return fi if command -v jq >/dev/null 2>&1; then # Fallback without python: empty (jq alone cannot easily top-N from free text) IP_HITS_JSON="[]" return fi IP_HITS_JSON="[]" } collect_nft_ip_hits() { local text text=$(nft list set inet evofw deny_v4 2>/dev/null || true) build_ip_hits_json "$text" } collect_ipset_ip_hits() { local text text=$(ipset list evofw_deny_v4 2>/dev/null || true) build_ip_hits_json "$text" } collect_ip_hits() { IP_HITS_JSON="[]" if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_v4 >/dev/null 2>&1; }; then collect_nft_ip_hits return fi if command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then collect_ipset_ip_hits fi } # Parse nft dynamic concat set → [{"ip","port","protocol","packets"},...] build_port_hits_json() { local text="$1" PORT_HITS_JSON="[]" if command -v python3 >/dev/null 2>&1; then PORT_HITS_JSON=$(PORT_HITS_TOP="$PORT_HITS_TOP" python3 -c ' import json, os, re, sys text = sys.stdin.read() top = int(os.environ.get("PORT_HITS_TOP", "500")) hits = {} # Elements look like: # 1.2.3.4 . tcp . 22 counter packets 10 bytes 100 # 1.2.3.4 . 6 . 443 timeout 1h counter packets 5 bytes 20 proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"} pat = re.compile( r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+" r"(?:timeout\s+\S+\s+)?(?:counter\s+)?packets\s+(\d+)", re.I, ) for m in pat.finditer(text): ip, raw_proto, port_s, pkts_s = m.group(1), m.group(2).lower(), m.group(3), m.group(4) proto = proto_map.get(raw_proto) if not proto: continue pkts = int(pkts_s) if pkts <= 0: continue port = int(port_s) if port < 1 or port > 65535: continue key = (ip, port, proto) hits[key] = max(hits.get(key, 0), pkts) items = [ {"ip": ip, "port": port, "protocol": proto, "packets": pkts} for (ip, port, proto), pkts in hits.items() ] items.sort(key=lambda x: x["packets"], reverse=True) print(json.dumps(items[:top], separators=(",", ":"))) ' <<<"$text" 2>/dev/null) || PORT_HITS_JSON="[]" return fi PORT_HITS_JSON="[]" } collect_nft_port_hits() { local text PORT_HITS_JSON="[]" text=$(nft list set inet evofw deny_port_hits 2>/dev/null || true) [[ -z "$text" ]] && return build_port_hits_json "$text" } collect_port_hits() { PORT_HITS_JSON="[]" if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_port_hits >/dev/null 2>&1; }; then collect_nft_port_hits fi } # Dynamic concat set for per-(ip, proto, dport) deny hits. Returns 0 if usable. ensure_nft_port_hits_set() { local table=$1 name=$2 local setname=deny_port_hits local def def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true) if [[ -n "$def" ]] && { [[ "$def" == *"dynamic"* ]] || [[ "$def" == *"timeout"* ]]; }; then # Keep existing; elements age out via timeout — do not flush on every apply # (counters survive policy CIDR refresh when chain is recreated). return 0 fi if [[ -n "$def" ]]; then nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true fi if nft add set "$table" "$name" "$setname" \ '{ type ipv4_addr . inet_proto . inet_service; flags dynamic,timeout; timeout 1h; counter; }' \ 2>>"$LOG_FILE"; then return 0 fi # Older kernels may need slightly different flag spelling. if nft add set "$table" "$name" "$setname" \ '{ type ipv4_addr . inet_proto . inet_service; flags dynamic; timeout 1h; counter; }' \ 2>>"$LOG_FILE"; then return 0 fi log "nft: deny_port_hits unsupported — port hits disabled" return 1 } apply_nft() { local table=inet name=evofw local deny_v4=() allow_v4=() p PORT_HITS_ENABLED=0 for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name" # Drop chain first so sets can be deleted/recreated (upgrade to counters / port hits). # Stats were already captured by the caller before apply_nft. nft delete chain "$table" "$name" input 2>/dev/null || true ensure_nft_set "$table" "$name" deny_v4 ensure_nft_set "$table" "$name" allow_v4 if ensure_nft_port_hits_set "$table" "$name"; then PORT_HITS_ENABLED=1 fi nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true local batch=() chunk=64 for p in "${deny_v4[@]}"; do batch+=("$p") if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"; batch=(); fi done ((${#batch[@]})) && nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}" batch=() for p in "${allow_v4[@]}"; do batch+=("$p") if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"; batch=(); fi done ((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" # Unified chain: deny → allow → default_action if [[ "$DEFAULT_ACTION" == "drop" ]]; then nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' else nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' fi nft add rule "$table" "$name" input ct state established,related counter accept nft add rule "$table" "$name" input iif lo counter accept if [[ "$PORT_HITS_ENABLED" -eq 1 ]]; then # TCP/UDP: learn (ip, proto, dport) then drop; other L4: plain drop. if ! nft add rule "$table" "$name" input \ ip saddr @deny_v4 meta l4proto '{ tcp, udp }' \ update @deny_port_hits '{ ip saddr . meta l4proto . th dport }' \ counter drop 2>>"$LOG_FILE"; then log "nft: port-hit deny rule failed — fallback to plain deny drop" PORT_HITS_ENABLED=0 nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop else nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop fi else nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop fi nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept # Port ACL: close (drop) then open (accept), before default. apply_nft_port_acl "$table" "$name" if [[ "$DEFAULT_ACTION" == "drop" ]]; then nft add rule "$table" "$name" input counter drop else nft add rule "$table" "$name" input counter accept fi KERNEL_METHOD=nft APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]})) } # Apply desired L4 port open/close rules from PORT_RULES_FILE (apply_version 3). apply_nft_port_acl() { local table=$1 name=$2 [[ -f "$PORT_RULES_FILE" ]] || return 0 if ! command -v python3 >/dev/null 2>&1; then log "nft port ACL skipped — need python3" return 0 fi # Delete prior per-rule src sets (name prefix port_src_) local setline setname while IFS= read -r setline; do setname=$(echo "$setline" | sed -n 's/.*set \(port_src_[a-zA-Z0-9_-]*\).*/\1/p') [[ -n "$setname" ]] || continue nft delete set "$table" "$name" "$setname" 2>/dev/null || true done < <(nft list table "$table" "$name" 2>/dev/null | grep -E 'set port_src_' || true) local cmds_file cmds_file=$(mktemp) python3 - "$PORT_RULES_FILE" >"$cmds_file" <<'PY' import json, sys, re path = sys.argv[1] try: rules = json.load(open(path, encoding="utf-8")) except Exception: rules = [] safe_id = re.compile(r"[^a-zA-Z0-9_]") for r in rules: rid = safe_id.sub("_", str(r.get("id") or "x"))[:40] action = r.get("action") or "open" proto = r.get("protocol") or "tcp" if proto not in ("tcp", "udp"): continue ps = int(r.get("port_start") or 0) pe = int(r.get("port_end") or ps) if ps < 1 or pe > 65535 or pe < ps: continue cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c] if not cidrs: continue verdict = "drop" if action == "close" else "accept" dport = f"{ps}" if ps == pe else f"{ps}-{pe}" comment = f"evofw-port-{rid}" is_all = any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs) if is_all: print(f'nft add rule inet evofw input {proto} dport {dport} counter {verdict} comment "{comment}"') continue setname = f"port_src_{rid}" print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'") chunk = [] for c in cidrs: chunk.append(c) if len(chunk) >= 32: joined = ", ".join(chunk) print(f"nft add element inet evofw {setname} '{{ {joined} }}'") chunk = [] if chunk: joined = ", ".join(chunk) print(f"nft add element inet evofw {setname} '{{ {joined} }}'") print( f'nft add rule inet evofw input ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"' ) PY local cmd while IFS= read -r cmd; do [[ -n "$cmd" ]] || continue # shellcheck disable=SC2086 eval "$cmd" 2>>"$LOG_FILE" || log "nft port ACL cmd failed: $cmd" done <"$cmds_file" rm -f "$cmds_file" } # Collect observed host firewall rules + listeners (best-effort). collect_host_firewall() { HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' if ! command -v python3 >/dev/null 2>&1; then return 0 fi local nft_txt="" ipt_txt="" ufw_txt="" fwd_txt="" ss_txt="" nft_txt=$(nft list ruleset 2>/dev/null || true) ipt_txt=$(iptables-save 2>/dev/null || true) if command -v ufw >/dev/null 2>&1; then ufw_txt=$(ufw status verbose 2>/dev/null || true) fi if command -v firewall-cmd >/dev/null 2>&1; then fwd_txt=$(firewall-cmd --list-all 2>/dev/null || true) fi ss_txt=$(ss -lntu 2>/dev/null || true) HOST_FIREWALL_JSON=$(NFT_TXT="$nft_txt" IPT_TXT="$ipt_txt" UFW_TXT="$ufw_txt" FWD_TXT="$fwd_txt" SS_TXT="$ss_txt" python3 - <<'PY' import json, os, re def ownership_of(text: str) -> str: t = text.lower() if "evofw" in t or "evofw-port-" in t: return "evofw" return "foreign" rules = [] listeners = [] nft = os.environ.get("NFT_TXT") or "" # Rough nft rule lines cur_table = "" cur_chain = "" for line in nft.splitlines(): ls = line.strip() if ls.startswith("table "): cur_table = ls cur_chain = "" continue m = re.match(r"chain\s+(\S+)", ls) if m: cur_chain = m.group(1) continue if not ls or ls.startswith("type ") or ls.startswith("policy ") or ls.startswith("set ") or ls.startswith("map "): continue if "accept" in ls or "drop" in ls or "reject" in ls or "jump " in ls or "goto " in ls: act = "accept" if " accept" in f" {ls}" or ls.endswith("accept") else ( "drop" if " drop" in f" {ls}" or ls.endswith("drop") else ( "reject" if "reject" in ls else "other" ) ) proto = "" if " tcp " in f" {ls}" or ls.startswith("tcp "): proto = "tcp" elif " udp " in f" {ls}" or ls.startswith("udp "): proto = "udp" dport = "" m = re.search(r"dport\s+(\S+)", ls) if m: dport = m.group(1) saddr = "" m = re.search(r"saddr\s+(\S+)", ls) if m: saddr = m.group(1).lstrip("@") raw = ls[:500] rules.append({ "ownership": ownership_of(cur_table + " " + cur_chain + " " + raw), "backend": "nft", "table": cur_table[:120], "chain": cur_chain[:120], "action": act, "protocol": proto or None, "dport": dport or None, "saddr": saddr or None, "raw": raw, }) ipt = os.environ.get("IPT_TXT") or "" cur_chain = "" for line in ipt.splitlines(): if line.startswith(":"): cur_chain = line[1:].split()[0] if line[1:] else "" continue if not line.startswith("-A "): continue parts = line.split(None, 2) chain = parts[1] if len(parts) > 1 else "" rest = parts[2] if len(parts) > 2 else line act = "DROP" if " -j DROP" in line else ( "ACCEPT" if " -j ACCEPT" in line else ( "REJECT" if " -j REJECT" in line else "other" ) ) proto = "" m = re.search(r"-p\s+(\w+)", line) if m: proto = m.group(1) dport = "" m = re.search(r"--dport(?:s)?\s+(\S+)", line) if m: dport = m.group(1) saddr = "" m = re.search(r"-s\s+(\S+)", line) if m: saddr = m.group(1) raw = line[:500] rules.append({ "ownership": ownership_of(raw), "backend": "iptables", "chain": chain[:120], "action": act.lower() if isinstance(act, str) else act, "protocol": proto or None, "dport": dport or None, "saddr": saddr or None, "raw": raw, }) ufw = os.environ.get("UFW_TXT") or "" for line in ufw.splitlines(): ls = line.strip() if not ls or ls.startswith("Status") or ls.startswith("Logging") or ls.startswith("Default") or ls.startswith("To") or ls.startswith("--"): continue if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls: rules.append({ "ownership": ownership_of(ls), "backend": "ufw", "action": "allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"), "raw": ls[:500], }) fwd = os.environ.get("FWD_TXT") or "" for line in fwd.splitlines(): ls = line.strip() if not ls: continue if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls: rules.append({ "ownership": "foreign", "backend": "firewalld", "raw": ls[:500], }) ss = os.environ.get("SS_TXT") or "" for line in ss.splitlines()[1:]: parts = line.split() if len(parts) < 5: continue proto = parts[0] local = parts[4] # *:22 or 0.0.0.0:22 or [::]:22 m = re.search(r"([^:]+):(\d+)$", local) if not m: # IPv6 [::]:port m = re.search(r"\[([^\]]+)\]:(\d+)$", local) if not m: continue addr, port_s = m.group(1), m.group(2) else: addr, port_s = m.group(1), m.group(2) try: port = int(port_s) except ValueError: continue listeners.append({ "protocol": "tcp" if proto.startswith("tcp") else ("udp" if proto.startswith("udp") else proto), "port": port, "address": addr, }) # Cap rules = rules[:500] listeners = listeners[:200] print(json.dumps({"rules": rules, "listeners": listeners}, separators=(",", ":"))) PY ) || HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' } ensure_ipset_counters() { local name=$1 if ! ipset list "$name" >/dev/null 2>&1; then if ipset create "$name" hash:net family inet counters 2>>"$LOG_FILE"; then return 0 fi ipset create "$name" hash:net family inet 2>>"$LOG_FILE" || { log "ipset: failed to create $name" return 1 } return 0 fi # Recreate once if set has no packet counters (Header lacks "counters"). local header header=$(ipset list "$name" 2>/dev/null | head -n 5 || true) if [[ "$header" == *"counters"* ]]; then return 0 fi # Cannot safely destroy while iptables may reference the set — leave as-is. log "ipset: $name has no counters (leave existing; per-IP hits unavailable)" } apply_ipset() { local dset=evofw_deny_v4 aset=evofw_allow_v4 ensure_ipset_counters "$dset" ensure_ipset_counters "$aset" ipset flush "$dset"; ipset flush "$aset" local p n=0 for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true iptables -D INPUT -j DROP 2>/dev/null || true # Unified: deny first, then allow, then optional default drop iptables -I INPUT -m set --match-set "$dset" src -j DROP iptables -I INPUT 2 -m set --match-set "$aset" src -j ACCEPT if [[ "$DEFAULT_ACTION" == "drop" ]]; then iptables -A INPUT -j DROP 2>/dev/null || true fi KERNEL_METHOD=ipset APPLIED=$n } send_report() { # If caller already collected (pre-apply), keep those values. if [[ -z "${STATS_CAPTURED:-}" ]]; then if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then collect_nft_stats fi fi if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then collect_ip_hits fi if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then collect_port_hits fi if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then collect_host_firewall fi local report # Compose report with python to safely embed host_firewall JSON if command -v python3 >/dev/null 2>&1; then report=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \ METHOD="${KERNEL_METHOD:-$BACKEND}" IP_HITS="${IP_HITS_JSON:-[]}" PORT_HITS="${PORT_HITS_JSON:-[]}" \ HOST_FW="${HOST_FIREWALL_JSON}" python3 - <<'PY' import json, os print(json.dumps({ "status": "ok", "prefix_count": int(os.environ.get("APPLIED") or 0), "packets_dropped": int(os.environ.get("DROPPED") or 0), "packets_accepted": int(os.environ.get("ACCEPTED") or 0), "kernel_method": os.environ.get("METHOD") or "auto", "source": "agent", "ip_hits": json.loads(os.environ.get("IP_HITS") or "[]"), "port_hits": json.loads(os.environ.get("PORT_HITS") or "[]"), "host_firewall": json.loads(os.environ.get("HOST_FW") or '{"rules":[],"listeners":[]}'), }, separators=(",", ":"))) PY ) else report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \ "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}") fi curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Content-Type: application/json" \ -d "$report" >/dev/null 2>&1 || true curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"source":"agent"}' >/dev/null 2>&1 || true } if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then log "unchanged hash $HASH — skip apply" if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then KERNEL_METHOD=nft elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then KERNEL_METHOD=ipset else KERNEL_METHOD="${BACKEND}" fi # Count applied prefixes from live sets when skipping apply. if [[ "$KERNEL_METHOD" == "nft" ]]; then APPLIED=$(nft list set inet evofw deny_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true) local_allow=$(nft list set inet evofw allow_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true) APPLIED=$((${APPLIED:-0} + ${local_allow:-0})) elif [[ "$KERNEL_METHOD" == "ipset" ]]; then APPLIED=$(ipset list evofw_deny_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}') local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}') APPLIED=$((${APPLIED:-0} + ${local_allow:-0})) fi collect_host_firewall HOST_FW_CAPTURED=1 send_report exit 0 fi # Capture counters BEFORE recreate (nft delete chain / flush set zeroes them). if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then collect_nft_stats collect_nft_ip_hits collect_nft_port_hits STATS_CAPTURED=1 IP_HITS_CAPTURED=1 PORT_HITS_CAPTURED=1 elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then collect_ipset_ip_hits IP_HITS_CAPTURED=1 PORT_HITS_JSON="[]" PORT_HITS_CAPTURED=1 fi collect_host_firewall HOST_FW_CAPTURED=1 case "$BACKEND" in nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft elif command -v ipset >/dev/null 2>&1; then apply_ipset else log "no backend"; exit 1; fi ;; ipset) apply_ipset ;; *) apply_nft ;; esac echo "$HASH" >"$HASH_FILE" log "applied default_action=$DEFAULT_ACTION count=$APPLIED method=$KERNEL_METHOD" send_report