refactor(api): enhance host firewall collection and JSON output
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m40s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Improved the `collect_host_firewall` function in `evofw-firewall.sh` to utilize temporary files for better handling of large rule sets, avoiding ARG_MAX limitations.
- Updated the JSON output structure to omit null/empty optional fields, ensuring compatibility with historical data formats.
- Enhanced error handling and logging for the firewall collection process, providing clearer diagnostics in case of failures.
- Adjusted related tests to accommodate changes in the expected output format, ensuring robust validation of the host firewall snapshot functionality.

These changes enhance the reliability and clarity of the host firewall data collection process, improving overall monitoring capabilities.
This commit is contained in:
Denozordec
2026-08-11 15:33:10 +07:00
parent 43f5ac2525
commit bfaed511bd
3 changed files with 149 additions and 79 deletions
+135 -69
View File
@@ -467,32 +467,65 @@ PY
collect_host_firewall() {
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
if ! command -v python3 >/dev/null 2>&1; then
log "host_firewall: python3 missing — empty snapshot"
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)
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_txt=$(ufw status verbose 2>/dev/null || true)
ufw status verbose >"$ufw_f" 2>/dev/null || true
else
: >"$ufw_f"
fi
if command -v firewall-cmd >/dev/null 2>&1; then
fwd_txt=$(firewall-cmd --list-all 2>/dev/null || true)
firewall-cmd --list-all >"$fwd_f" 2>/dev/null || true
else
: >"$fwd_f"
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'
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 or "evofw-port-" in t:
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 = os.environ.get("NFT_TXT") or ""
# Rough nft rule lines
nft = read(os.environ["NFT_F"])
cur_table = ""
cur_chain = ""
for line in nft.splitlines():
@@ -526,20 +559,19 @@ for line in nft.splitlines():
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,
})
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 = os.environ.get("IPT_TXT") or ""
ipt = read(os.environ["IPT_F"])
cur_chain = ""
for line in ipt.splitlines():
if line.startswith(":"):
@@ -549,7 +581,6 @@ for line in ipt.splitlines():
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"
@@ -567,54 +598,51 @@ for line in ipt.splitlines():
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,
})
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 = os.environ.get("UFW_TXT") or ""
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({
"ownership": ownership_of(ls),
"backend": "ufw",
"action": "allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
"raw": ls[:500],
})
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 = os.environ.get("FWD_TXT") or ""
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({
"ownership": "foreign",
"backend": "firewalld",
"raw": ls[:500],
})
rules.append(rule(
ownership="foreign",
backend="firewalld",
raw=ls[:500],
))
ss = os.environ.get("SS_TXT") or ""
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]
# *: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
@@ -631,12 +659,22 @@ for line in ss.splitlines()[1:]:
"address": addr,
})
# Cap
rules = rules[:500]
listeners = listeners[:200]
print(json.dumps({"rules": rules, "listeners": listeners}, separators=(",", ":")))
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
) || HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
then
if [[ -f "$out_f" ]]; then
HOST_FIREWALL_JSON=$(cat "$out_f")
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"
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
fi
rm -rf "$tmpdir" 2>/dev/null || true
}
ensure_ipset_counters() {
@@ -698,34 +736,62 @@ send_report() {
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
collect_host_firewall
fi
local report
# Compose report with python to safely embed host_firewall JSON
local report_file http_code counts
report_file=$(mktemp "${STATE_DIR}/report.XXXXXX")
# Compose report via files — large host_firewall must not go through env ARG_MAX.
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'
local host_fw_file hits_file ports_file
host_fw_file=$(mktemp "${STATE_DIR}/hostfwj.XXXXXX")
hits_file=$(mktemp "${STATE_DIR}/iphits.XXXXXX")
ports_file=$(mktemp "${STATE_DIR}/porthits.XXXXXX")
printf '%s' "${HOST_FIREWALL_JSON:-{\"rules\":[],\"listeners\":[]}}" >"$host_fw_file"
printf '%s' "${IP_HITS_JSON:-[]}" >"$hits_file"
printf '%s' "${PORT_HITS_JSON:-[]}" >"$ports_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="$hits_file" \
PORT_HITS_F="$ports_file" OUT_F="$report_file" python3 - <<'PY'
import json, os
print(json.dumps({
def load(path, default):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
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": 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=(",", ":")))
"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 [])
print(f"{n_rules} {n_listen}")
PY
)
) || counts="0 0"
rm -f "$host_fw_file" "$hits_file" "$ports_file" 2>/dev/null || true
log "host_firewall snapshot rules=${counts%% *} listeners=${counts##* }"
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:-[]}")
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:-[]}" \
>"$report_file"
fi
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
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" \
-d "$report" >/dev/null 2>&1 || true
--data-binary @"$report_file" 2>/dev/null || echo "000")
if [[ "$http_code" != "200" ]]; then
log "apply-report failed http=${http_code} 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" \
+4
View File
@@ -247,6 +247,10 @@ describe('port ACL + host firewall snapshot', () => {
backend: 'iptables',
chain: 'INPUT',
action: 'ACCEPT',
// Agent historically sent JSON null for missing fields — must accept.
protocol: null,
dport: null,
saddr: null,
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
},
],