Files
EvoFirewall/apps/api/src/agent-scripts/evofw-firewall.sh
T
Denozordec a2ad637a38
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m20s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api, web): enhance port ACL handling and documentation
- Updated the `evofw-firewall.sh` script to refine the port ACL logic, ensuring the correct order of operations for deny and allow rules.
- Introduced a new structure for port ACL rows in the UI, allowing for better management of system and EvoFW rules.
- Enhanced the documentation to clarify the new port ACL behavior, including implicit drops for open ports and the distinction between EvoFW and system rules.
- Improved the handling of port ranges and source addresses in the UI, ensuring accurate representation of firewall rules.

These changes improve the functionality and clarity of port ACL management, enhancing user experience and system reliability.
2026-08-16 16:28:11 +07:00

972 lines
33 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}"
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
mkdir -p "$STATE_DIR"
file_sha256() {
local f=$1
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$f" 2>/dev/null | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$f" 2>/dev/null | awk '{print $NF}'
else
echo ""
fi
}
# Pull a newer sync script from CP before policy (pending agents too).
# Errors must not abort this run — keep the current script.
maybe_self_update() {
if [[ "${EVOFW_SKIP_SELF_UPDATE:-}" == "1" ]]; then
return 0
fi
if [[ ! -f "$SYNC_SCRIPT" ]]; then
return 0
fi
local local_sha tmp code remote_sha
local_sha=$(file_sha256 "$SYNC_SCRIPT")
tmp=$(mktemp "${STATE_DIR}/sync-script.XXXXXX") || return 0
if [[ -n "$local_sha" ]]; then
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
-H "If-None-Match: \"${local_sha}\"" \
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
else
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
fi
if [[ "$code" == "304" ]]; then
rm -f "$tmp"
return 0
fi
if [[ "$code" != "200" ]]; then
log "self-update: sync-script HTTP ${code} — keep current"
rm -f "$tmp"
return 0
fi
if ! head -n1 "$tmp" | grep -q '^#!'; then
log "self-update: sync-script is not a shell script — keep current"
rm -f "$tmp"
return 0
fi
remote_sha=$(file_sha256 "$tmp")
if [[ -z "$remote_sha" ]]; then
log "self-update: cannot hash download — keep current"
rm -f "$tmp"
return 0
fi
if [[ -n "$local_sha" && "$remote_sha" == "$local_sha" ]]; then
rm -f "$tmp"
return 0
fi
if ! install -m 755 "$tmp" "$SYNC_SCRIPT"; then
log "self-update: install failed — keep current"
rm -f "$tmp"
return 0
fi
rm -f "$tmp"
rm -f "$HASH_FILE"
log "self-update: installed script sha256=${remote_sha} — re-exec"
exec env EVOFW_SKIP_SELF_UPDATE=1 "$SYNC_SCRIPT" || {
log "self-update: exec failed — continue current"
return 0
}
}
maybe_self_update
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
# Snapshot / hits live on disk — never via bash ${var:-{...}} (} truncates expansion).
HOST_FW_FILE="${STATE_DIR}/host_firewall.json"
IP_HITS_FILE="${STATE_DIR}/ip_hits.json"
PORT_HITS_FILE="${STATE_DIR}/port_hits.json"
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
printf '%s' '[]' >"$IP_HITS_FILE"
printf '%s' '[]' >"$PORT_HITS_FILE"
# 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/ipset listing from file → IP_HITS_FILE (top-N JSON).
build_ip_hits_from_file() {
local src="$1"
printf '%s' '[]' >"$IP_HITS_FILE"
[[ -f "$src" ]] || return 0
if ! command -v python3 >/dev/null 2>&1; then
return 0
fi
IP_HITS_TOP="$IP_HITS_TOP" IN_F="$src" OUT_F="$IP_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$IP_HITS_FILE"
import json, os, re
path = os.environ["IN_F"]
top = int(os.environ.get("IP_HITS_TOP", "200"))
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
text = ""
hits = {}
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)
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)
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
json.dump(items[:top], f, separators=(",", ":"))
print(len(items[:top]))
PY
}
collect_nft_ip_hits() {
local dump="${STATE_DIR}/nft_deny_v4.txt"
nft list set inet evofw deny_v4 >"$dump" 2>/dev/null || : >"$dump"
build_ip_hits_from_file "$dump"
}
collect_ipset_ip_hits() {
local dump="${STATE_DIR}/ipset_deny_v4.txt"
ipset list evofw_deny_v4 >"$dump" 2>/dev/null || : >"$dump"
build_ip_hits_from_file "$dump"
}
collect_ip_hits() {
printf '%s' '[]' >"$IP_HITS_FILE"
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 from file → PORT_HITS_FILE.
build_port_hits_from_file() {
local src="$1"
printf '%s' '[]' >"$PORT_HITS_FILE"
[[ -f "$src" && -s "$src" ]] || return 0
if ! command -v python3 >/dev/null 2>&1; then
return 0
fi
PORT_HITS_TOP="$PORT_HITS_TOP" IN_F="$src" OUT_F="$PORT_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$PORT_HITS_FILE"
import json, os, re
path = os.environ["IN_F"]
top = int(os.environ.get("PORT_HITS_TOP", "500"))
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
text = ""
hits = {}
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)
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
json.dump(items[:top], f, separators=(",", ":"))
print(len(items[:top]))
PY
}
collect_nft_port_hits() {
local dump="${STATE_DIR}/nft_deny_port_hits.txt"
printf '%s' '[]' >"$PORT_HITS_FILE"
nft list set inet evofw deny_port_hits >"$dump" 2>/dev/null || : >"$dump"
[[ -s "$dump" ]] || return 0
build_port_hits_from_file "$dump"
}
collect_port_hits() {
printf '%s' '[]' >"$PORT_HITS_FILE"
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 → Port ACL (close/open/implicit) → 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
# Port ACL before L3 allow so open+list is exclusive (implicit drop per open port).
apply_nft_port_acl "$table" "$name"
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
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_]")
def parse_rule(r):
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"):
return None
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:
return None
cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
if not cidrs:
return None
dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
return {
"rid": rid,
"action": action,
"proto": proto,
"dport": dport,
"cidrs": cidrs,
"is_all": any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs),
}
def emit(p):
verdict = "drop" if p["action"] == "close" else "accept"
comment = f"evofw-port-{p['rid']}"
proto, dport = p["proto"], p["dport"]
if p["is_all"]:
print(f'nft add rule inet evofw input {proto} dport {dport} counter {verdict} comment "{comment}"')
return
setname = f"port_src_{p['rid']}_{p['proto']}"
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
chunk = []
for c in p["cidrs"]:
chunk.append(c)
if len(chunk) >= 32:
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
chunk = []
if chunk:
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
print(
f'nft add rule inet evofw input ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"'
)
parsed = [p for p in (parse_rule(r) for r in rules) if p]
closes = [p for p in parsed if p["action"] == "close"]
opens = [p for p in parsed if p["action"] != "close"]
for p in closes:
emit(p)
for p in opens:
emit(p)
seen = set()
for p in opens:
key = (p["proto"], p["dport"])
if key in seen:
continue
seen.add(key)
comment = f"evofw-port-implicit-{p['proto']}-{p['dport']}"
print(
f'nft add rule inet evofw input {p["proto"]} dport {p["dport"]} counter drop 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() {
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
if ! command -v python3 >/dev/null 2>&1; then
log "host_firewall: python3 missing — empty snapshot"
return 0
fi
local tmpdir nft_f ipt_f ufw_f fwd_f ss_f out_f
tmpdir=$(mktemp -d "${STATE_DIR}/hostfw.XXXXXX") || return 0
nft_f="$tmpdir/nft.txt"
ipt_f="$tmpdir/ipt.txt"
ufw_f="$tmpdir/ufw.txt"
fwd_f="$tmpdir/fwd.txt"
ss_f="$tmpdir/ss.txt"
out_f="$tmpdir/out.json"
# Dump to files — env vars blow ARG_MAX on large nft rulesets.
nft list ruleset >"$nft_f" 2>/dev/null || true
iptables-save >"$ipt_f" 2>/dev/null || true
if command -v ufw >/dev/null 2>&1; then
ufw status verbose >"$ufw_f" 2>/dev/null || true
else
: >"$ufw_f"
fi
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --list-all >"$fwd_f" 2>/dev/null || true
else
: >"$fwd_f"
fi
ss -lntu >"$ss_f" 2>/dev/null || true
if NFT_F="$nft_f" IPT_F="$ipt_f" UFW_F="$ufw_f" FWD_F="$fwd_f" SS_F="$ss_f" OUT_F="$out_f" python3 - <<'PY' >/tmp/evofw-hostfw-counts.txt 2>>"$LOG_FILE"
import json, os, re
def read(path: str) -> str:
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return ""
def ownership_of(text: str) -> str:
t = text.lower()
if "evofw" in t:
return "evofw"
return "foreign"
def rule(**kwargs):
# Omit null/empty optional fields — Zod optional rejects JSON null.
out = {
"ownership": kwargs["ownership"],
"backend": kwargs["backend"],
"raw": kwargs["raw"][:512],
}
for k in ("table", "chain", "action", "protocol", "dport", "sport", "saddr", "daddr", "comment"):
v = kwargs.get(k)
if v is not None and v != "":
out[k] = v if not isinstance(v, str) else v[:128 if k in ("table", "chain", "saddr", "daddr") else (64 if k in ("action", "dport", "sport", "protocol") else 256)]
return out
rules = []
listeners = []
nft = read(os.environ["NFT_F"])
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("@")
rules.append(rule(
ownership=ownership_of(cur_table + " " + cur_chain + " " + ls),
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=ls[:500],
))
ipt = read(os.environ["IPT_F"])
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 ""
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)
rules.append(rule(
ownership=ownership_of(line),
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=line[:500],
))
ufw = read(os.environ["UFW_F"])
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(rule(
ownership=ownership_of(ls),
backend="ufw",
action="allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
raw=ls[:500],
))
fwd = read(os.environ["FWD_F"])
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(rule(
ownership="foreign",
backend="firewalld",
raw=ls[:500],
))
ss = read(os.environ["SS_F"])
for line in ss.splitlines()[1:]:
parts = line.split()
if len(parts) < 5:
continue
proto = parts[0]
local = parts[4]
m = re.search(r"([^:]+):(\d+)$", local)
if not m:
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,
})
rules = rules[:500]
listeners = listeners[:200]
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
json.dump({"rules": rules, "listeners": listeners}, f, separators=(",", ":"))
print(f"{len(rules)} {len(listeners)}")
PY
then
if [[ -f "$out_f" ]]; then
cp "$out_f" "$HOST_FW_FILE"
log "host_firewall collected $(tr -d '\r\n' </tmp/evofw-hostfw-counts.txt 2>/dev/null || echo '?')"
fi
else
log "host_firewall: collect failed — empty snapshot"
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
fi
rm -rf "$tmpdir" 2>/dev/null || true
}
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_file http_code counts n_rules n_listen n_ip n_port
report_file=$(mktemp "${STATE_DIR}/report.XXXXXX")
# Compose report via files only — never bash ${var:-{...}} (} closes expansion).
if command -v python3 >/dev/null 2>&1; then
[[ -f "$HOST_FW_FILE" ]] || printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
[[ -f "$IP_HITS_FILE" ]] || printf '%s' '[]' >"$IP_HITS_FILE"
[[ -f "$PORT_HITS_FILE" ]] || printf '%s' '[]' >"$PORT_HITS_FILE"
counts=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \
METHOD="${KERNEL_METHOD:-$BACKEND}" HOST_FW_F="$HOST_FW_FILE" IP_HITS_F="$IP_HITS_FILE" \
PORT_HITS_F="$PORT_HITS_FILE" OUT_F="$report_file" python3 - <<'PY' 2>>"$LOG_FILE"
import json, os
def load(path, default):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"load_error {path}: {e}", file=__import__("sys").stderr)
return default
payload = {
"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": load(os.environ["IP_HITS_F"], []),
"port_hits": load(os.environ["PORT_HITS_F"], []),
"host_firewall": load(os.environ["HOST_FW_F"], {"rules": [], "listeners": []}),
}
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
json.dump(payload, f, separators=(",", ":"))
n_rules = len(payload["host_firewall"].get("rules") or [])
n_listen = len(payload["host_firewall"].get("listeners") or [])
n_ip = len(payload["ip_hits"] or [])
n_port = len(payload["port_hits"] or [])
print(f"{n_rules} {n_listen} {n_ip} {n_port}")
PY
) || counts="0 0 0 0"
# shellcheck disable=SC2086
set -- $counts
n_rules=${1:-0}
n_listen=${2:-0}
n_ip=${3:-0}
n_port=${4:-0}
else
printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":[],"port_hits":[]}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" \
>"$report_file"
n_rules=0
n_listen=0
n_ip=0
n_port=0
fi
http_code=$(curl -sS -o /tmp/evofw-apply-report.out -w '%{http_code}' -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @"$report_file" 2>/dev/null || echo "000")
log "apply-report http=${http_code} host_fw=${n_rules}/${n_listen} ip_hits=${n_ip} port_hits=${n_port}"
if [[ "$http_code" != "200" ]]; then
log "apply-report failed body=$(head -c 200 /tmp/evofw-apply-report.out 2>/dev/null || true)"
fi
rm -f "$report_file" 2>/dev/null || 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
printf '%s' '[]' >"$PORT_HITS_FILE"
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