From bfaed511bd70bd00da3249d79005b9cd9fd4eed0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 11 Aug 2026 15:33:10 +0700 Subject: [PATCH] refactor(api): enhance host firewall collection and JSON output - 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. --- apps/api/src/agent-scripts/evofw-firewall.sh | 204 ++++++++++++------- apps/api/src/services/port-acl.test.ts | 4 + packages/shared/src/contracts.ts | 20 +- 3 files changed, 149 insertions(+), 79 deletions(-) diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index bd58471..6985c25 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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' /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" \ diff --git a/apps/api/src/services/port-acl.test.ts b/apps/api/src/services/port-acl.test.ts index b4566e1..00b67f9 100644 --- a/apps/api/src/services/port-acl.test.ts +++ b/apps/api/src/services/port-acl.test.ts @@ -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', }, ], diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index df5968f..bafbbd0 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -245,15 +245,15 @@ export const hostFwBackendSchema = z.enum([ export const hostFwRuleSchema = z.object({ ownership: hostFwOwnershipSchema, backend: hostFwBackendSchema, - table: z.string().max(128).optional(), - chain: z.string().max(128).optional(), - action: z.string().max(64).optional(), - protocol: z.string().max(16).optional(), - dport: z.string().max(64).optional(), - sport: z.string().max(64).optional(), - saddr: z.string().max(128).optional(), - daddr: z.string().max(128).optional(), - comment: z.string().max(256).optional(), + table: z.string().max(128).nullish(), + chain: z.string().max(128).nullish(), + action: z.string().max(64).nullish(), + protocol: z.string().max(16).nullish(), + dport: z.string().max(64).nullish(), + sport: z.string().max(64).nullish(), + saddr: z.string().max(128).nullish(), + daddr: z.string().max(128).nullish(), + comment: z.string().max(256).nullish(), raw: z.string().max(512), }) @@ -261,7 +261,7 @@ export const hostListenerSchema = z.object({ protocol: z.string().max(16), port: z.number().int().min(0).max(65535), address: z.string().max(128), - process: z.string().max(128).optional(), + process: z.string().max(128).nullish(), }) export const hostFirewallPayloadSchema = z.object({