- Introduced a new `sanitizeHostFirewall` function to filter and validate host firewall rules and listeners, ensuring only valid entries are processed. - Updated the `apply-report` endpoint to prevent overwriting existing host firewall snapshots with empty payloads, improving data integrity. - Enhanced the `applyReportHostFirewallSchema` to define the expected structure for host firewall data, allowing for better validation and error handling. - Added tests to verify the behavior of the new sanitization logic and the preservation of existing snapshots, ensuring robustness in the API's handling of firewall data. These changes improve the reliability and accuracy of host firewall data management within the API, enhancing overall monitoring capabilities.
873 lines
30 KiB
Bash
873 lines
30 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
|
|
# 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 → 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() {
|
|
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
|