feat(api): enhance host firewall handling and validation
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m56s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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.
This commit is contained in:
Denozordec
2026-08-15 13:27:49 +07:00
parent bfaed511bd
commit ef2a333430
4 changed files with 239 additions and 78 deletions
+91 -74
View File
@@ -108,9 +108,13 @@ PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED=0
IP_HITS_JSON="[]"
PORT_HITS_JSON="[]"
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
# 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
@@ -181,53 +185,54 @@ collect_nft_stats() {
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()
# 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 = {}
# 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="[]"
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 text
text=$(nft list set inet evofw deny_v4 2>/dev/null || true)
build_ip_hits_json "$text"
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 text
text=$(ipset list evofw_deny_v4 2>/dev/null || true)
build_ip_hits_json "$text"
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() {
IP_HITS_JSON="[]"
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
@@ -237,19 +242,24 @@ collect_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()
# 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 = {}
# 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+"
@@ -274,23 +284,22 @@ items = [
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="[]"
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 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"
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() {
PORT_HITS_JSON="[]"
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
@@ -465,7 +474,7 @@ PY
# Collect observed host firewall rules + listeners (best-effort).
collect_host_firewall() {
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
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
@@ -667,12 +676,12 @@ print(f"{len(rules)} {len(listeners)}")
PY
then
if [[ -f "$out_f" ]]; then
HOST_FIREWALL_JSON=$(cat "$out_f")
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"
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
fi
rm -rf "$tmpdir" 2>/dev/null || true
}
@@ -736,27 +745,24 @@ send_report() {
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
collect_host_firewall
fi
local report_file http_code counts
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 — large host_firewall must not go through env ARG_MAX.
# Compose report via files only — never bash ${var:-{...}} (} closes expansion).
if command -v python3 >/dev/null 2>&1; then
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"
[[ -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="$hits_file" \
PORT_HITS_F="$ports_file" OUT_F="$report_file" python3 - <<'PY'
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:
except Exception as e:
print(f"load_error {path}: {e}", file=__import__("sys").stderr)
return default
payload = {
@@ -774,22 +780,33 @@ 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}")
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"
rm -f "$host_fw_file" "$hits_file" "$ports_file" 2>/dev/null || true
log "host_firewall snapshot rules=${counts%% *} listeners=${counts##* }"
) || 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":%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":[],"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 http=${http_code} body=$(head -c 200 /tmp/evofw-apply-report.out 2>/dev/null || true)"
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" \
@@ -834,7 +851,7 @@ if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&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="[]"
printf '%s' '[]' >"$PORT_HITS_FILE"
PORT_HITS_CAPTURED=1
fi
collect_host_firewall
+34 -3
View File
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
import { enrollBodySchema, applyReportBodySchema, hostFwRuleSchema, hostListenerSchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { hashToken } from '../plugins/auth.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
@@ -14,6 +14,26 @@ import { resolveAndRenderInstall } from '../services/install-links.js'
const scriptsDir = resolveAgentScriptsDir()
function sanitizeHostFirewall(raw: {
rules?: unknown[]
listeners?: unknown[]
}): { rules: unknown[]; listeners: unknown[] } {
const rules: unknown[] = []
for (const item of raw.rules ?? []) {
const parsed = hostFwRuleSchema.safeParse(item)
if (parsed.success) rules.push(parsed.data)
}
const listeners: unknown[] = []
for (const item of raw.listeners ?? []) {
const parsed = hostListenerSchema.safeParse(item)
if (parsed.success) listeners.push(parsed.data)
}
return {
rules: rules.slice(0, 500),
listeners: listeners.slice(0, 200),
}
}
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
@@ -247,9 +267,20 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
}
if (body.host_firewall && body.source !== 'mikrotik') {
const sanitized = sanitizeHostFirewall(body.host_firewall)
// Empty snapshot must not wipe a previous good collect (agent bash bugs / race).
if (
sanitized.rules.length === 0 &&
sanitized.listeners.length === 0
) {
const existing = repos.getHostFirewallSnapshot(app.db, agentId)
if (existing) {
return { ok: true }
}
}
const payloadJson = JSON.stringify({
rules: body.host_firewall.rules ?? [],
listeners: body.host_firewall.listeners ?? [],
rules: sanitized.rules,
listeners: sanitized.listeners,
})
const rawDigest = createHash('sha256')
.update(payloadJson)
+107
View File
@@ -278,6 +278,113 @@ describe('port ACL + host firewall snapshot', () => {
expect(body.listeners[0]?.port).toBe(22)
})
it('does not wipe host_firewall snapshot with empty apply-report payload', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'host-fw-empty',
'evofw_host_fw_empty_token_ab',
)
const filled = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
host_firewall: {
rules: [
{
ownership: 'evofw',
backend: 'nft',
action: 'drop',
raw: 'ip saddr @deny_v4 drop',
},
],
listeners: [{ protocol: 'tcp', port: 22, address: '0.0.0.0' }],
},
},
})
expect(filled.statusCode).toBe(200)
const empty = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
host_firewall: { rules: [], listeners: [] },
},
})
expect(empty.statusCode).toBe(200)
const snap = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/host-firewall`,
})
const body = snap.json() as {
rules: unknown[]
listeners: unknown[]
}
expect(body.rules).toHaveLength(1)
expect(body.listeners).toHaveLength(1)
})
it('filters invalid host_firewall rules without failing apply-report', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'host-fw-filter',
'evofw_host_fw_filter_token_a',
)
const report = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
host_firewall: {
rules: [
{ ownership: 'evofw', backend: 'nft', raw: 'ok rule' },
{ ownership: 'nope', backend: 'nft', raw: 'bad ownership' },
{ not: 'a rule' },
],
listeners: [
{ protocol: 'tcp', port: 443, address: '::' },
{ protocol: 'tcp', port: 99999, address: '1.1.1.1' },
],
},
},
})
expect(report.statusCode).toBe(200)
const snap = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/host-firewall`,
})
const body = snap.json() as {
rules: unknown[]
listeners: { port: number }[]
}
expect(body.rules).toHaveLength(1)
expect(body.listeners).toHaveLength(1)
expect(body.listeners[0]?.port).toBe(443)
})
it('rejects port ACL on non-linux agents', async () => {
const app = await appPromise
await app.ready()
+7 -1
View File
@@ -269,6 +269,12 @@ export const hostFirewallPayloadSchema = z.object({
listeners: z.array(hostListenerSchema).max(200).default([]),
})
/** Wire format for apply-report — unknown elements filtered in route (safeParse). */
export const applyReportHostFirewallSchema = z.object({
rules: z.array(z.unknown()).max(500).optional().default([]),
listeners: z.array(z.unknown()).max(200).optional().default([]),
})
export const applyReportBodySchema = z.object({
status: z.string(),
prefix_count: z.number().int().optional(),
@@ -282,7 +288,7 @@ export const applyReportBodySchema = z.object({
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
/** Observed host firewall + listeners (Linux). */
host_firewall: hostFirewallPayloadSchema.optional(),
host_firewall: applyReportHostFirewallSchema.optional(),
})
export const agentPortRuleActionSchema = z.enum(['open', 'close'])