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
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user