diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 6985c25..ff6ffa5 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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' /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 diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 1ced2f4..f9ad3f5 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -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) diff --git a/apps/api/src/services/port-acl.test.ts b/apps/api/src/services/port-acl.test.ts index 00b67f9..730332c 100644 --- a/apps/api/src/services/port-acl.test.ts +++ b/apps/api/src/services/port-acl.test.ts @@ -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() diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index bafbbd0..172552f 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -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'])