- 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.
790 lines
27 KiB
Bash
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
|