feat(api): enhance host firewall handling and validation
- 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:
@@ -108,9 +108,13 @@ PACKETS_DROPPED=0
|
|||||||
PACKETS_ACCEPTED=0
|
PACKETS_ACCEPTED=0
|
||||||
KERNEL_METHOD=""
|
KERNEL_METHOD=""
|
||||||
APPLIED=0
|
APPLIED=0
|
||||||
IP_HITS_JSON="[]"
|
# Snapshot / hits live on disk — never via bash ${var:-{...}} (} truncates expansion).
|
||||||
PORT_HITS_JSON="[]"
|
HOST_FW_FILE="${STATE_DIR}/host_firewall.json"
|
||||||
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
|
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.
|
# 1 when deny_port_hits dynamic set is available for this apply.
|
||||||
PORT_HITS_ENABLED=0
|
PORT_HITS_ENABLED=0
|
||||||
|
|
||||||
@@ -181,53 +185,54 @@ collect_nft_stats() {
|
|||||||
done < <(nft list chain inet evofw input 2>/dev/null || true)
|
done < <(nft list chain inet evofw input 2>/dev/null || true)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse nft set / ipset listing → top-N JSON [{"ip":"...","packets":N},...]
|
# Parse nft/ipset listing from file → IP_HITS_FILE (top-N JSON).
|
||||||
build_ip_hits_json() {
|
build_ip_hits_from_file() {
|
||||||
local text="$1"
|
local src="$1"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||||
IP_HITS_JSON=$(IP_HITS_TOP="$IP_HITS_TOP" python3 -c '
|
[[ -f "$src" ]] || return 0
|
||||||
import json, os, re, sys
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
text = sys.stdin.read()
|
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"))
|
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 = {}
|
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):
|
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))
|
ip, pkts = m.group(1), int(m.group(2))
|
||||||
if pkts > 0:
|
if pkts > 0:
|
||||||
hits[ip] = max(hits.get(ip, 0), pkts)
|
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):
|
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))
|
ip, pkts = m.group(1), int(m.group(2))
|
||||||
if pkts > 0:
|
if pkts > 0:
|
||||||
hits[ip] = max(hits.get(ip, 0), pkts)
|
hits[ip] = max(hits.get(ip, 0), pkts)
|
||||||
items = [{"ip": k, "packets": v} for k, v in hits.items()]
|
items = [{"ip": k, "packets": v} for k, v in hits.items()]
|
||||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||||
print(json.dumps(items[:top], separators=(",", ":")))
|
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||||
' <<<"$text" 2>/dev/null) || IP_HITS_JSON="[]"
|
json.dump(items[:top], f, separators=(",", ":"))
|
||||||
return
|
print(len(items[:top]))
|
||||||
fi
|
PY
|
||||||
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="[]"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_nft_ip_hits() {
|
collect_nft_ip_hits() {
|
||||||
local text
|
local dump="${STATE_DIR}/nft_deny_v4.txt"
|
||||||
text=$(nft list set inet evofw deny_v4 2>/dev/null || true)
|
nft list set inet evofw deny_v4 >"$dump" 2>/dev/null || : >"$dump"
|
||||||
build_ip_hits_json "$text"
|
build_ip_hits_from_file "$dump"
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_ipset_ip_hits() {
|
collect_ipset_ip_hits() {
|
||||||
local text
|
local dump="${STATE_DIR}/ipset_deny_v4.txt"
|
||||||
text=$(ipset list evofw_deny_v4 2>/dev/null || true)
|
ipset list evofw_deny_v4 >"$dump" 2>/dev/null || : >"$dump"
|
||||||
build_ip_hits_json "$text"
|
build_ip_hits_from_file "$dump"
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_ip_hits() {
|
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
|
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
|
collect_nft_ip_hits
|
||||||
return
|
return
|
||||||
@@ -237,19 +242,24 @@ collect_ip_hits() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse nft dynamic concat set → [{"ip","port","protocol","packets"},...]
|
# Parse nft dynamic concat set from file → PORT_HITS_FILE.
|
||||||
build_port_hits_json() {
|
build_port_hits_from_file() {
|
||||||
local text="$1"
|
local src="$1"
|
||||||
PORT_HITS_JSON="[]"
|
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
[[ -f "$src" && -s "$src" ]] || return 0
|
||||||
PORT_HITS_JSON=$(PORT_HITS_TOP="$PORT_HITS_TOP" python3 -c '
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
import json, os, re, sys
|
return 0
|
||||||
text = sys.stdin.read()
|
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"))
|
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 = {}
|
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"}
|
proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"}
|
||||||
pat = re.compile(
|
pat = re.compile(
|
||||||
r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+"
|
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()
|
for (ip, port, proto), pkts in hits.items()
|
||||||
]
|
]
|
||||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||||
print(json.dumps(items[:top], separators=(",", ":")))
|
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||||
' <<<"$text" 2>/dev/null) || PORT_HITS_JSON="[]"
|
json.dump(items[:top], f, separators=(",", ":"))
|
||||||
return
|
print(len(items[:top]))
|
||||||
fi
|
PY
|
||||||
PORT_HITS_JSON="[]"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_nft_port_hits() {
|
collect_nft_port_hits() {
|
||||||
local text
|
local dump="${STATE_DIR}/nft_deny_port_hits.txt"
|
||||||
PORT_HITS_JSON="[]"
|
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||||
text=$(nft list set inet evofw deny_port_hits 2>/dev/null || true)
|
nft list set inet evofw deny_port_hits >"$dump" 2>/dev/null || : >"$dump"
|
||||||
[[ -z "$text" ]] && return
|
[[ -s "$dump" ]] || return 0
|
||||||
build_port_hits_json "$text"
|
build_port_hits_from_file "$dump"
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_port_hits() {
|
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
|
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
|
collect_nft_port_hits
|
||||||
fi
|
fi
|
||||||
@@ -465,7 +474,7 @@ PY
|
|||||||
|
|
||||||
# Collect observed host firewall rules + listeners (best-effort).
|
# Collect observed host firewall rules + listeners (best-effort).
|
||||||
collect_host_firewall() {
|
collect_host_firewall() {
|
||||||
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
|
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||||
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"
|
log "host_firewall: python3 missing — empty snapshot"
|
||||||
return 0
|
return 0
|
||||||
@@ -667,12 +676,12 @@ print(f"{len(rules)} {len(listeners)}")
|
|||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
if [[ -f "$out_f" ]]; 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 '?')"
|
log "host_firewall collected $(tr -d '\r\n' </tmp/evofw-hostfw-counts.txt 2>/dev/null || echo '?')"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "host_firewall: collect failed — empty snapshot"
|
log "host_firewall: collect failed — empty snapshot"
|
||||||
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
|
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||||
fi
|
fi
|
||||||
rm -rf "$tmpdir" 2>/dev/null || true
|
rm -rf "$tmpdir" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
@@ -736,27 +745,24 @@ send_report() {
|
|||||||
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
|
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
|
||||||
collect_host_firewall
|
collect_host_firewall
|
||||||
fi
|
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")
|
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
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
local host_fw_file hits_file ports_file
|
[[ -f "$HOST_FW_FILE" ]] || printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||||
host_fw_file=$(mktemp "${STATE_DIR}/hostfwj.XXXXXX")
|
[[ -f "$IP_HITS_FILE" ]] || printf '%s' '[]' >"$IP_HITS_FILE"
|
||||||
hits_file=$(mktemp "${STATE_DIR}/iphits.XXXXXX")
|
[[ -f "$PORT_HITS_FILE" ]] || printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||||
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}" \
|
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" \
|
METHOD="${KERNEL_METHOD:-$BACKEND}" HOST_FW_F="$HOST_FW_FILE" IP_HITS_F="$IP_HITS_FILE" \
|
||||||
PORT_HITS_F="$ports_file" OUT_F="$report_file" python3 - <<'PY'
|
PORT_HITS_F="$PORT_HITS_FILE" OUT_F="$report_file" python3 - <<'PY' 2>>"$LOG_FILE"
|
||||||
import json, os
|
import json, os
|
||||||
|
|
||||||
def load(path, default):
|
def load(path, default):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f"load_error {path}: {e}", file=__import__("sys").stderr)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -774,22 +780,33 @@ with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
|||||||
json.dump(payload, f, separators=(",", ":"))
|
json.dump(payload, f, separators=(",", ":"))
|
||||||
n_rules = len(payload["host_firewall"].get("rules") or [])
|
n_rules = len(payload["host_firewall"].get("rules") or [])
|
||||||
n_listen = len(payload["host_firewall"].get("listeners") 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
|
PY
|
||||||
) || counts="0 0"
|
) || counts="0 0 0 0"
|
||||||
rm -f "$host_fw_file" "$hits_file" "$ports_file" 2>/dev/null || true
|
# shellcheck disable=SC2086
|
||||||
log "host_firewall snapshot rules=${counts%% *} listeners=${counts##* }"
|
set -- $counts
|
||||||
|
n_rules=${1:-0}
|
||||||
|
n_listen=${2:-0}
|
||||||
|
n_ip=${3:-0}
|
||||||
|
n_port=${4:-0}
|
||||||
else
|
else
|
||||||
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":[],"port_hits":[]}' \
|
||||||
"${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}" \
|
||||||
>"$report_file"
|
>"$report_file"
|
||||||
|
n_rules=0
|
||||||
|
n_listen=0
|
||||||
|
n_ip=0
|
||||||
|
n_port=0
|
||||||
fi
|
fi
|
||||||
http_code=$(curl -sS -o /tmp/evofw-apply-report.out -w '%{http_code}' -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" \
|
||||||
--data-binary @"$report_file" 2>/dev/null || echo "000")
|
--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
|
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
|
fi
|
||||||
rm -f "$report_file" 2>/dev/null || true
|
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" \
|
||||||
@@ -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
|
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
|
||||||
collect_ipset_ip_hits
|
collect_ipset_ip_hits
|
||||||
IP_HITS_CAPTURED=1
|
IP_HITS_CAPTURED=1
|
||||||
PORT_HITS_JSON="[]"
|
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||||
PORT_HITS_CAPTURED=1
|
PORT_HITS_CAPTURED=1
|
||||||
fi
|
fi
|
||||||
collect_host_firewall
|
collect_host_firewall
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { repos } from '@evofw/db'
|
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 type { AppConfig } from '../config.js'
|
||||||
import { hashToken } from '../plugins/auth.js'
|
import { hashToken } from '../plugins/auth.js'
|
||||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||||
@@ -14,6 +14,26 @@ import { resolveAndRenderInstall } from '../services/install-links.js'
|
|||||||
|
|
||||||
const scriptsDir = resolveAgentScriptsDir()
|
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 (
|
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
app,
|
app,
|
||||||
opts,
|
opts,
|
||||||
@@ -247,9 +267,20 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
|
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
|
||||||
}
|
}
|
||||||
if (body.host_firewall && body.source !== 'mikrotik') {
|
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({
|
const payloadJson = JSON.stringify({
|
||||||
rules: body.host_firewall.rules ?? [],
|
rules: sanitized.rules,
|
||||||
listeners: body.host_firewall.listeners ?? [],
|
listeners: sanitized.listeners,
|
||||||
})
|
})
|
||||||
const rawDigest = createHash('sha256')
|
const rawDigest = createHash('sha256')
|
||||||
.update(payloadJson)
|
.update(payloadJson)
|
||||||
|
|||||||
@@ -278,6 +278,113 @@ describe('port ACL + host firewall snapshot', () => {
|
|||||||
expect(body.listeners[0]?.port).toBe(22)
|
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 () => {
|
it('rejects port ACL on non-linux agents', async () => {
|
||||||
const app = await appPromise
|
const app = await appPromise
|
||||||
await app.ready()
|
await app.ready()
|
||||||
|
|||||||
@@ -269,6 +269,12 @@ export const hostFirewallPayloadSchema = z.object({
|
|||||||
listeners: z.array(hostListenerSchema).max(200).default([]),
|
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({
|
export const applyReportBodySchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
prefix_count: z.number().int().optional(),
|
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). */
|
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
|
||||||
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
|
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
|
||||||
/** Observed host firewall + listeners (Linux). */
|
/** Observed host firewall + listeners (Linux). */
|
||||||
host_firewall: hostFirewallPayloadSchema.optional(),
|
host_firewall: applyReportHostFirewallSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
|
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
|
||||||
|
|||||||
Reference in New Issue
Block a user