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() { collect_host_firewall() {
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
log "host_firewall: python3 missing — empty snapshot"
return 0 return 0
fi fi
local nft_txt="" ipt_txt="" ufw_txt="" fwd_txt="" ss_txt="" local tmpdir nft_f ipt_f ufw_f fwd_f ss_f out_f
nft_txt=$(nft list ruleset 2>/dev/null || true) tmpdir=$(mktemp -d "${STATE_DIR}/hostfw.XXXXXX") || return 0
ipt_txt=$(iptables-save 2>/dev/null || true) 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 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 fi
if command -v firewall-cmd >/dev/null 2>&1; then 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 fi
ss_txt=$(ss -lntu 2>/dev/null || true) ss -lntu >"$ss_f" 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'
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 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: def ownership_of(text: str) -> str:
t = text.lower() t = text.lower()
if "evofw" in t or "evofw-port-" in t: if "evofw" in t:
return "evofw" return "evofw"
return "foreign" 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 = [] rules = []
listeners = [] listeners = []
nft = os.environ.get("NFT_TXT") or "" nft = read(os.environ["NFT_F"])
# Rough nft rule lines
cur_table = "" cur_table = ""
cur_chain = "" cur_chain = ""
for line in nft.splitlines(): for line in nft.splitlines():
@@ -526,20 +559,19 @@ for line in nft.splitlines():
m = re.search(r"saddr\s+(\S+)", ls) m = re.search(r"saddr\s+(\S+)", ls)
if m: if m:
saddr = m.group(1).lstrip("@") saddr = m.group(1).lstrip("@")
raw = ls[:500] rules.append(rule(
rules.append({ ownership=ownership_of(cur_table + " " + cur_chain + " " + ls),
"ownership": ownership_of(cur_table + " " + cur_chain + " " + raw), backend="nft",
"backend": "nft", table=cur_table[:120],
"table": cur_table[:120], chain=cur_chain[:120],
"chain": cur_chain[:120], action=act,
"action": act, protocol=proto or None,
"protocol": proto or None, dport=dport or None,
"dport": dport or None, saddr=saddr or None,
"saddr": saddr or None, raw=ls[:500],
"raw": raw, ))
})
ipt = os.environ.get("IPT_TXT") or "" ipt = read(os.environ["IPT_F"])
cur_chain = "" cur_chain = ""
for line in ipt.splitlines(): for line in ipt.splitlines():
if line.startswith(":"): if line.startswith(":"):
@@ -549,7 +581,6 @@ for line in ipt.splitlines():
continue continue
parts = line.split(None, 2) parts = line.split(None, 2)
chain = parts[1] if len(parts) > 1 else "" 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 ( act = "DROP" if " -j DROP" in line else (
"ACCEPT" if " -j ACCEPT" in line else ( "ACCEPT" if " -j ACCEPT" in line else (
"REJECT" if " -j REJECT" in line else "other" "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) m = re.search(r"-s\s+(\S+)", line)
if m: if m:
saddr = m.group(1) saddr = m.group(1)
raw = line[:500] rules.append(rule(
rules.append({ ownership=ownership_of(line),
"ownership": ownership_of(raw), backend="iptables",
"backend": "iptables", chain=chain[:120],
"chain": chain[:120], action=act.lower() if isinstance(act, str) else act,
"action": act.lower() if isinstance(act, str) else act, protocol=proto or None,
"protocol": proto or None, dport=dport or None,
"dport": dport or None, saddr=saddr or None,
"saddr": saddr or None, raw=line[:500],
"raw": raw, ))
})
ufw = os.environ.get("UFW_TXT") or "" ufw = read(os.environ["UFW_F"])
for line in ufw.splitlines(): for line in ufw.splitlines():
ls = line.strip() 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("--"): if not ls or ls.startswith("Status") or ls.startswith("Logging") or ls.startswith("Default") or ls.startswith("To") or ls.startswith("--"):
continue continue
if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls: if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls:
rules.append({ rules.append(rule(
"ownership": ownership_of(ls), ownership=ownership_of(ls),
"backend": "ufw", backend="ufw",
"action": "allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"), action="allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
"raw": ls[:500], raw=ls[:500],
}) ))
fwd = os.environ.get("FWD_TXT") or "" fwd = read(os.environ["FWD_F"])
for line in fwd.splitlines(): for line in fwd.splitlines():
ls = line.strip() ls = line.strip()
if not ls: if not ls:
continue continue
if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls: if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls:
rules.append({ rules.append(rule(
"ownership": "foreign", ownership="foreign",
"backend": "firewalld", backend="firewalld",
"raw": ls[:500], raw=ls[:500],
}) ))
ss = os.environ.get("SS_TXT") or "" ss = read(os.environ["SS_F"])
for line in ss.splitlines()[1:]: for line in ss.splitlines()[1:]:
parts = line.split() parts = line.split()
if len(parts) < 5: if len(parts) < 5:
continue continue
proto = parts[0] proto = parts[0]
local = parts[4] local = parts[4]
# *:22 or 0.0.0.0:22 or [::]:22
m = re.search(r"([^:]+):(\d+)$", local) m = re.search(r"([^:]+):(\d+)$", local)
if not m: if not m:
# IPv6 [::]:port
m = re.search(r"\[([^\]]+)\]:(\d+)$", local) m = re.search(r"\[([^\]]+)\]:(\d+)$", local)
if not m: if not m:
continue continue
@@ -631,12 +659,22 @@ for line in ss.splitlines()[1:]:
"address": addr, "address": addr,
}) })
# Cap
rules = rules[:500] rules = rules[:500]
listeners = listeners[:200] 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 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() { ensure_ipset_counters() {
@@ -698,34 +736,62 @@ send_report() {
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
collect_host_firewall collect_host_firewall
fi fi
local report local report_file http_code counts
# Compose report with python to safely embed host_firewall JSON 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 if command -v python3 >/dev/null 2>&1; then
report=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \ local host_fw_file hits_file ports_file
METHOD="${KERNEL_METHOD:-$BACKEND}" IP_HITS="${IP_HITS_JSON:-[]}" PORT_HITS="${PORT_HITS_JSON:-[]}" \ host_fw_file=$(mktemp "${STATE_DIR}/hostfwj.XXXXXX")
HOST_FW="${HOST_FIREWALL_JSON}" python3 - <<'PY' 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 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", "status": "ok",
"prefix_count": int(os.environ.get("APPLIED") or 0), "prefix_count": int(os.environ.get("APPLIED") or 0),
"packets_dropped": int(os.environ.get("DROPPED") or 0), "packets_dropped": int(os.environ.get("DROPPED") or 0),
"packets_accepted": int(os.environ.get("ACCEPTED") or 0), "packets_accepted": int(os.environ.get("ACCEPTED") or 0),
"kernel_method": os.environ.get("METHOD") or "auto", "kernel_method": os.environ.get("METHOD") or "auto",
"source": "agent", "source": "agent",
"ip_hits": json.loads(os.environ.get("IP_HITS") or "[]"), "ip_hits": load(os.environ["IP_HITS_F"], []),
"port_hits": json.loads(os.environ.get("PORT_HITS") or "[]"), "port_hits": load(os.environ["PORT_HITS_F"], []),
"host_firewall": json.loads(os.environ.get("HOST_FW") or '{"rules":[],"listeners":[]}'), "host_firewall": load(os.environ["HOST_FW_F"], {"rules": [], "listeners": []}),
}, separators=(",", ":"))) }
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 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 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}' \ 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:-[]}") "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}" \
>"$report_file"
fi 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 "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \ -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" \ curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
+4
View File
@@ -247,6 +247,10 @@ describe('port ACL + host firewall snapshot', () => {
backend: 'iptables', backend: 'iptables',
chain: 'INPUT', chain: 'INPUT',
action: 'ACCEPT', 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', raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
}, },
], ],
+10 -10
View File
@@ -245,15 +245,15 @@ export const hostFwBackendSchema = z.enum([
export const hostFwRuleSchema = z.object({ export const hostFwRuleSchema = z.object({
ownership: hostFwOwnershipSchema, ownership: hostFwOwnershipSchema,
backend: hostFwBackendSchema, backend: hostFwBackendSchema,
table: z.string().max(128).optional(), table: z.string().max(128).nullish(),
chain: z.string().max(128).optional(), chain: z.string().max(128).nullish(),
action: z.string().max(64).optional(), action: z.string().max(64).nullish(),
protocol: z.string().max(16).optional(), protocol: z.string().max(16).nullish(),
dport: z.string().max(64).optional(), dport: z.string().max(64).nullish(),
sport: z.string().max(64).optional(), sport: z.string().max(64).nullish(),
saddr: z.string().max(128).optional(), saddr: z.string().max(128).nullish(),
daddr: z.string().max(128).optional(), daddr: z.string().max(128).nullish(),
comment: z.string().max(256).optional(), comment: z.string().max(256).nullish(),
raw: z.string().max(512), raw: z.string().max(512),
}) })
@@ -261,7 +261,7 @@ export const hostListenerSchema = z.object({
protocol: z.string().max(16), protocol: z.string().max(16),
port: z.number().int().min(0).max(65535), port: z.number().int().min(0).max(65535),
address: z.string().max(128), address: z.string().max(128),
process: z.string().max(128).optional(), process: z.string().max(128).nullish(),
}) })
export const hostFirewallPayloadSchema = z.object({ export const hostFirewallPayloadSchema = z.object({