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 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
+34 -3
View File
@@ -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)
+107
View File
@@ -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()
+7 -1
View File
@@ -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'])