Files
EvoFirewall/apps/api/src/agent-scripts/evofw-firewall.sh
T
Denozordec 43f5ac2525
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m43s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api, web): implement port ACL and host firewall snapshot features
- Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports.
- Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management.
- Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring.
- Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience.
- Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers.

These changes significantly improve the management and visibility of firewall rules and port access control for agents.
2026-08-11 15:08:00 +07:00

790 lines
27 KiB
Bash

#!/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