feat: harden firewall policies telemetry and operations
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped

Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
This commit is contained in:
Denozordec committed 2026-10-26 13:10:58 +07:00
1 parent aa475848fd
commit 9fa6269174
242 files changed
+19872 -3057

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"build": "tsup src/server.ts --format esm --dts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"start": "node dist/server.js",
"test": "vitest run"
},
File diff suppressed because it is too large. Load diff
+67 -30
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# EvoFirewall Linux install one-liner
# Re-run on an already-installed host updates scripts/timer **and forces nft
# rule re-apply** (clears last_hash), keeping credentials
# rule re-apply** (clears kernel verification), keeping credentials
# (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
set -euo pipefail
@@ -49,13 +49,12 @@ ensure_pkg() {
ensure_pkg curl curl || exit 1
need_cmd bash || { echo "missing bash" >&2; exit 1; }
# JSON parse for enroll; python3 is an acceptable fallback in sync script.
if ! need_cmd jq && ! need_cmd python3; then
ensure_pkg jq jq || ensure_pkg python3 python3 || {
echo "evofw install: need jq or python3" >&2
exit 1
}
fi
# Policy validation and nft batch generation require Python; jq alone is not
# sufficient. util-linux supplies flock for serialized timer/manual sync runs.
ensure_pkg python3 python3 || exit 1
ensure_pkg flock util-linux || exit 1
ensure_pkg timeout coreutils || exit 1
# Firewall tools: never reinstall/replace if already present.
if need_cmd nft; then
@@ -63,8 +62,8 @@ if need_cmd nft; then
elif need_cmd iptables; then
echo "evofw install: iptables already present — skip nftables/iptables install"
if ! need_cmd ipset; then
echo "evofw install: installing ipset (optional companion for iptables)..."
pkg_install ipset 2>/dev/null || true
echo "evofw install: installing ipset (required companion for iptables)..."
ensure_pkg ipset ipset || exit 1
fi
else
echo "evofw install: no nft/iptables — installing firewall backend..."
@@ -111,7 +110,7 @@ if [[ "$HAS_SYSTEMD" -ne 1 ]]; then
fi
: "${EVOFW_CP_URL:?EVOFW_CP_URL required}"
: "${EVOFW_SEED:?EVOFW_SEED required}"
: "${EVOFW_ENROLLMENT_CREDENTIAL:?Scoped EVOFW_ENROLLMENT_CREDENTIAL required}"
: "${EVOFW_CLIENT_NAME:?EVOFW_CLIENT_NAME required}"
CONF_DIR=/etc/evofw
@@ -161,14 +160,24 @@ write_conf() {
printf 'CLIENT_TOKEN=%s\n' "$(shell_quote "$client_token")"
printf 'CLIENT_NAME=%s\n' "$(shell_quote "$client_name")"
printf 'KERNEL_BACKEND=%s\n' "$(shell_quote "$backend")"
} >"$CONF_FILE"
chmod 600 "$CONF_FILE"
} >"${CONF_FILE}.new"
chmod 600 "${CONF_FILE}.new"
python3 - "${CONF_FILE}.new" "$CONF_FILE" <<'PY_CONF_COMMIT'
import os, sys
with open(sys.argv[1], 'rb') as value: os.fsync(value.fileno())
os.replace(sys.argv[1], sys.argv[2])
fd = os.open(os.path.dirname(sys.argv[2]), os.O_RDONLY)
try: os.fsync(fd)
finally: os.close(fd)
PY_CONF_COMMIT
}
install_sync_and_uninstall() {
local sync_tmp=$1
install -m 755 "$sync_tmp" "$SYNC_SCRIPT"
if curl -fsSL "${CP_URL}/v1/agent/uninstall.sh" -o "$UNINSTALL_SCRIPT" 2>/dev/null; then
bash -n "$sync_tmp" || return 1
install -m 755 "$sync_tmp" "${SYNC_SCRIPT}.new"
mv -f "${SYNC_SCRIPT}.new" "$SYNC_SCRIPT"
if curl --connect-timeout 10 --max-time 60 -fsSL "${CP_URL}/v1/agent/uninstall.sh" -o "$UNINSTALL_SCRIPT" 2>/dev/null; then
chmod 755 "$UNINSTALL_SCRIPT"
else
echo "evofw install: warning — could not download uninstall.sh (optional)" >&2
@@ -214,7 +223,7 @@ UNIT
download_sync_script() {
local out=$1
if ! curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$out"; then
if ! curl --connect-timeout 10 --max-time 60 -fsSL "${CP_URL}/v1/agent/sync-script" -o "$out"; then
echo "failed to download sync script from ${CP_URL}/v1/agent/sync-script" >&2
return 1
fi
@@ -222,6 +231,7 @@ download_sync_script() {
echo "sync script is not a shell script (CP returned unexpected body)" >&2
return 1
fi
bash -n "$out"
}
SYNC_TMP=$(mktemp)
@@ -251,33 +261,56 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
download_sync_script "$SYNC_TMP" || exit 1
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
install_sync_and_uninstall "$SYNC_TMP"
# Force one apply after script refresh (nft set upgrade, counters, port hits, etc.).
rm -f /var/lib/evofw/last_hash
# Invalidate structural verification to force reconciliation after refresh.
rm -f /var/lib/evofw/kernel_state
enable_scheduler_and_run
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed; nft rules re-applied."
echo "Force sync: $SYNC_SCRIPT"
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl --connect-timeout 10 --max-time 60 -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
exit 0
fi
# --- Fresh install (or EVOFW_INSTALL_FORCE=1) ---
CLIENT_TOKEN="$(gen_token)"
mkdir -p "$CONF_DIR" /var/lib/evofw
chmod 700 "$CONF_DIR"
exec 8>/var/lib/evofw/sync.lock
flock -x 8
ENROLL_PENDING="$CONF_DIR/enrollment-pending.json"
CLIENT_TOKEN=$(python3 - "$ENROLL_PENDING" "$CP_URL" "${EVOFW_INSTALL_LINK_ID:-}" <<'PY_ENROLL_PENDING'
import json, os, secrets, sys
from pathlib import Path
path = Path(sys.argv[1]); origin, link = sys.argv[2:]
if not link: raise SystemExit('install link required')
if path.exists():
state = json.loads(path.read_text())
if state.get('origin') != origin or state.get('link') != link: raise SystemExit('pending enrollment belongs to another link')
else:
state = {'origin': origin, 'link': link, 'token': 'evofw_' + secrets.token_urlsafe(32)}
fd = os.open(str(path) + '.new', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as out:
json.dump(state, out); out.flush(); os.fsync(out.fileno())
os.replace(str(path) + '.new', path)
fd = os.open(str(path.parent), os.O_RDONLY)
try: os.fsync(fd)
finally: os.close(fd)
print(state['token'])
PY_ENROLL_PENDING
)
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
# Fail fast: pull sync script before enroll so we never leave a DB agent without a local agent.
download_sync_script "$SYNC_TMP" || exit 1
if [[ -n "${EVOFW_INSTALL_LINK_ID:-}" ]]; then
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1","install_link_id":"%s"}' \
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN" "$EVOFW_INSTALL_LINK_ID")
else
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
fi
ENROLL_BODY=$(python3 - "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN" "$EVOFW_INSTALL_LINK_ID" <<'PY_ENROLL_BODY'
import json, sys
name, hostname, platform, token, link = sys.argv[1:]
print(json.dumps(dict(name=name, hostname=hostname, platform=platform, token=token, client_version='install.sh/2', install_link_id=link)))
PY_ENROLL_BODY
)
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \
ENROLL_CODE=$(curl --connect-timeout 10 --max-time 60 -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoFW-Seed: ${EVOFW_SEED}" \
-H "X-EvoFW-Enrollment-Credential: ${EVOFW_ENROLLMENT_CREDENTIAL}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" && "$ENROLL_CODE" != "200" ]]; then
echo "enroll failed: HTTP ${ENROLL_CODE}" >&2
@@ -304,10 +337,14 @@ if [[ -z "$BACKEND" ]]; then
fi
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$EVOFW_CLIENT_NAME" "$BACKEND"
# Config is committed before pending enrollment can be removed.
rm -f "$ENROLL_PENDING"
flock -u 8
exec 8>&-
install_sync_and_uninstall "$SYNC_TMP"
enable_scheduler_and_run
echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI (rules optional — can assign later)."
echo "If still offline after Approve, run: $SYNC_SCRIPT"
echo "Re-run the same install URL to update scripts without re-enroll."
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl --connect-timeout 10 --max-time 60 -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
+517 -114
View File
@@ -1,18 +1,18 @@
# EvoFirewall MikroTik install (RouterOS 7.21+)
# Short-link sets EvofwCpUrl / EvofwSeed / EvofwName / EvofwInstallLinkId before body.
# Short-link sets EvofwCpUrl / EvofwEnrollmentCredential / EvofwName / EvofwInstallLinkId before body.
#
# One script only: evofw-sync (credentials + sync). Legacy evofw-env is removed.
# Sync: GET /v1/agent/policy (JSON) → rebuild address-lists. LastHash in RAM (full rebuild after reboot).
# Sync: bounded JSON policy -> staged canonical owned rules. Rebuild after reboot.
:global EvofwCpUrl
:global EvofwSeed
:global EvofwEnrollmentCredential
:global EvofwName
:global EvofwInstallLinkId
:global EvofwToken
:global EvofwLastHash
:if ([:typeof $EvofwCpUrl] = "nothing" || [:len $EvofwCpUrl] = 0) do={ :error "EvofwCpUrl required" }
:if ([:typeof $EvofwSeed] = "nothing" || [:len $EvofwSeed] = 0) do={ :error "EvofwSeed required" }
:if ([:typeof $EvofwEnrollmentCredential] = "nothing" || [:len $EvofwEnrollmentCredential] = 0) do={ :error "EvofwEnrollmentCredential required" }
:if ([:typeof $EvofwName] = "nothing" || [:len $EvofwName] = 0) do={ :set EvofwName [/system identity get name] }
:local already 0
@@ -43,61 +43,74 @@
} on-error={}
}
:local credentialFile "evofw-credentials.json"
:if ([:len [/file find name="flash"]] > 0) do={
:local flashType [/file get [find name="flash"] type]
:if ($flashType = "disk" || $flashType = "directory") do={ :set credentialFile "flash/evofw-credentials.json" }
}
:local credentials {"origin"=$EvofwCpUrl}
:if ([:len [/file find name=$credentialFile]] > 0) do={
:set credentials [:deserialize [/file get [find name=$credentialFile] contents] from=json options=json.no-string-conversion]
:if (($credentials->"origin") != $EvofwCpUrl) do={ :error "credential controller mismatch" }
:if ([:typeof ($credentials->"active_token")] = "str" && [:len ($credentials->"active_token")] >= 16) do={ :set EvofwToken ($credentials->"active_token"); :set already 1 }
}
:if ($already = 0) do={
:local token ("evofw_" . [:rndstr length=32 from="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"])
:if ([:len $token] < 20) do={
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]] . [:tostr [:rndnum from=100000 to=999999]])
}
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/6\"")
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
}
:set body ($body . "}")
:do {
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no
} on-error={
:error "evofw: enroll failed — check EvofwCpUrl / EvofwSeed / connectivity"
:if ([:typeof $EvofwInstallLinkId] != "str" || [:len $EvofwInstallLinkId] = 0) do={ :error "scoped install link required" }
:if ([:typeof ($credentials->"enrollment_token")] != "str") do={
:set ($credentials->"enrollment_token") ("evofw_" . [:rndstr length=43 from="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"])
:set ($credentials->"install_link_id") $EvofwInstallLinkId
:if ([:len [/file find name=$credentialFile]] = 0) do={ /file add name=$credentialFile type=file }
/file set [find name=$credentialFile] contents=[:serialize $credentials to=json options=json.no-string-conversion]
}
:if (($credentials->"install_link_id") != $EvofwInstallLinkId) do={ :error "pending enrollment belongs to another link" }
:local token ($credentials->"enrollment_token")
:local body {"name"=$EvofwName;"hostname"=[/system identity get name];"platform"="mikrotik";"token"=$token;"client_version"="rsc/10";"install_link_id"=$EvofwInstallLinkId}
:local result [/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Enrollment-Credential: " . $EvofwEnrollmentCredential) http-data=[:serialize $body to=json options=json.no-string-conversion] output=user as-value duration=1m]
:if (($result->"status") != "finished") do={ :error "enrollment ACK unavailable; saved token retained" }
:local ack [:deserialize ($result->"data") from=json options=json.no-string-conversion]
:if ([:typeof ($ack->"client_id")] != "str" || [:len ($ack->"client_id")] = 0) do={ :error "invalid enrollment ACK" }
:set ($credentials->"active_token") $token
:set ($credentials->"client_id") ($ack->"client_id")
:set ($credentials->"enrollment_token") ""
/file set [find name=$credentialFile] contents=[:serialize $credentials to=json options=json.no-string-conversion]
:set EvofwToken $token
} else={
:put "evofw: already enrolled — updating single sync script (token kept)"
:put "evofw: already enrolled - updating single sync script (token kept)"
}
:set EvofwLastHash ""
# Filter rules (idempotent by comment) — always at the top of each chain.
# Deny path: hit (add-src → EVOFW_HITS) then drop. Allow/default unchanged.
:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={}
# Preserve active owned rules and their counters when refreshing the installer.
:local inputFirst [/ip firewall filter find where chain=input]
:local inputFirst [/ip firewall filter find where chain=input and dynamic=no]
:local inputPb ""
:if ([:len $inputFirst] > 0) do={ :set inputPb ($inputFirst->0) }
:local forwardFirst [/ip firewall filter find where chain=forward]
:local forwardFirst [/ip firewall filter find where chain=forward and dynamic=no]
:local forwardPb ""
:if ([:len $forwardFirst] > 0) do={ :set forwardPb ($forwardFirst->0) }
# place-before same anchor repeatedly → earlier adds stay above later ones.
:if ([:len $inputPb] > 0) do={
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no place-before=$inputPb
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no place-before=$inputPb
:if ([:len [/ip firewall filter find comment=evofw-deny-hit-input]] = 0) do={ /ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no place-before=$inputPb }
:if ([:len [/ip firewall filter find comment=evofw-deny-drop-input]] = 0) do={ /ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no place-before=$inputPb }
} else={
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no
:if ([:len [/ip firewall filter find comment=evofw-deny-hit-input]] = 0) do={ /ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no }
:if ([:len [/ip firewall filter find comment=evofw-deny-drop-input]] = 0) do={ /ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no }
}
:if ([:len $forwardPb] > 0) do={
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no place-before=$forwardPb
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no place-before=$forwardPb
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no place-before=$forwardPb
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes place-before=$forwardPb
:if ([:len [/ip firewall filter find comment=evofw-deny-hit-forward]] = 0) do={ /ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no place-before=$forwardPb }
:if ([:len [/ip firewall filter find comment=evofw-deny-drop-forward]] = 0) do={ /ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no place-before=$forwardPb }
:if ([:len [/ip firewall filter find comment=evofw-allow-accept-forward]] = 0) do={ /ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no place-before=$forwardPb }
:if ([:len [/ip firewall filter find comment=evofw-default-drop-forward]] = 0) do={ /ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes place-before=$forwardPb }
} else={
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes
:if ([:len [/ip firewall filter find comment=evofw-deny-hit-forward]] = 0) do={ /ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no }
:if ([:len [/ip firewall filter find comment=evofw-deny-drop-forward]] = 0) do={ /ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no }
:if ([:len [/ip firewall filter find comment=evofw-allow-accept-forward]] = 0) do={ /ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no }
:if ([:len [/ip firewall filter find comment=evofw-default-drop-forward]] = 0) do={ /ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes }
}
# Build sync body as a template script, then prepend baked credentials → one script
@@ -106,105 +119,495 @@
:global EvofwCpUrl
:global EvofwToken
:global EvofwLastHash
:local credentialFile "evofw-credentials.json"
:if ([:len [/file find name="flash"]] > 0) do={
:local flashType [/file get [find name="flash"] type]
:if ($flashType = "disk" || $flashType = "directory") do={ :set credentialFile "flash/evofw-credentials.json" }
}
:local credentials {"origin"=$EvofwCpUrl;"active_token"=$EvofwToken}
:if ([:len [/file find name=$credentialFile]] > 0) do={
:set credentials [:deserialize [/file get [find name=$credentialFile] contents] from=json options=json.no-string-conversion]
:if (($credentials->"origin") != $EvofwCpUrl || [:typeof ($credentials->"active_token")] != "str") do={ :error "invalid active credential state" }
:set EvofwToken ($credentials->"active_token")
}
:if ([:typeof $EvofwCpUrl] = "nothing" || [:len $EvofwCpUrl] = 0 || [:typeof $EvofwToken] = "nothing" || [:len $EvofwToken] = 0) do={
:log error "evofw: missing EvofwCpUrl/EvofwToken"
:error "evofw missing credentials"
}
:if ([:typeof $EvofwLastHash] = "nothing") do={ :set EvofwLastHash "" }
:if ([/system script job print count-only where script="evofw-sync"] > 1) do={ :error "evofw sync already running" }
# Resume the saved exchange before policy or telemetry. Failed ACK keeps active token unchanged.
:do {
:if ([:typeof ($credentials->"rotation")] != "array") do={
:local result [/tool fetch url=($EvofwCpUrl . "/v1/agent/credentials") http-header-field=("Authorization: Bearer " . $EvofwToken) output=user as-value duration=1m]
:if (($result->"status") = "finished") do={
:local status [:deserialize ($result->"data") from=json options=json.no-string-conversion]
:if ([:typeof ($status->"rotation")] = "array") do={
:set ($credentials->"client_id") ($status->"client_id")
:set ($credentials->"rotation") {"request_id"=(($status->"rotation")->"request_id");"old_token"=$EvofwToken;"next_token"=("evofw_" . [:rndstr length=43 from="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"])}
:if ([:len [/file find name=$credentialFile]] = 0) do={ /file add name=$credentialFile type=file }
/file set [find name=$credentialFile] contents=[:serialize $credentials to=json options=json.no-string-conversion]
}
}
}
} on-error={ :if ([:typeof ($credentials->"rotation")] = "array") do={ :error "cannot persist pending credential exchange" } }
:if ([:typeof ($credentials->"rotation")] = "array") do={
:local rotation ($credentials->"rotation")
:local body {"request_id"=($rotation->"request_id");"next_token"=($rotation->"next_token")}
:local ack [:toarray ""]
:local abandoned false
:do {
:local result [/tool fetch url=($EvofwCpUrl . "/v1/agent/token-rotation") http-method=post http-header-field=("Content-Type: application/json,Authorization: Bearer " . ($rotation->"old_token")) http-data=[:serialize $body to=json options=json.no-string-conversion] output=user as-value duration=1m]
:if (($result->"status") != "finished") do={ :error "credential exchange pending" }
:set ack [:deserialize ($result->"data") from=json options=json.no-string-conversion]
} on-error={
:do {
# After replay expiry, the saved candidate can prove that it is already active.
:local proof [/tool fetch url=($EvofwCpUrl . "/v1/agent/credentials") http-header-field=("Authorization: Bearer " . ($rotation->"next_token")) output=user as-value duration=1m]
:if (($proof->"status") != "finished") do={ :error "credential recovery pending" }
:local identity [:deserialize ($proof->"data") from=json options=json.no-string-conversion]
:if (($identity->"client_id") != ($credentials->"client_id") || (($identity->"completed_rotation")->"request_id") != ($rotation->"request_id")) do={ :error "candidate identity mismatch" }
:set ack {"ok"=true;"request_id"=($rotation->"request_id");"client_id"=($credentials->"client_id")}
} on-error={
# An authenticated old-token read proves no exchange committed. Never
# discard a request still active on the controller after a transient failure.
:local proof [/tool fetch url=($EvofwCpUrl . "/v1/agent/credentials") http-header-field=("Authorization: Bearer " . ($rotation->"old_token")) output=user as-value duration=1m]
:if (($proof->"status") != "finished") do={ :error "old credential recovery pending" }
:local identity [:deserialize ($proof->"data") from=json options=json.no-string-conversion]
:if (($identity->"client_id") != ($credentials->"client_id")) do={ :error "old credential identity mismatch" }
:if ([:typeof ($identity->"rotation")] = "array" && (($identity->"rotation")->"request_id") = ($rotation->"request_id")) do={ :error "exchange request still active" }
:set ($credentials->"rotation") ""
/file set [find name=$credentialFile] contents=[:serialize $credentials to=json options=json.no-string-conversion]
:set abandoned true
# Continue policy/telemetry using the proven old credential; poll newer request next sync.
}
}
:if (!$abandoned) do={
:if (($ack->"ok") != true || ($ack->"request_id") != ($rotation->"request_id") || ($ack->"client_id") != ($credentials->"client_id")) do={ :error "credential ACK mismatch" }
:set ($credentials->"active_token") ($rotation->"next_token")
:set ($credentials->"rotation") ""
/file set [find name=$credentialFile] contents=[:serialize $credentials to=json options=json.no-string-conversion]
:set EvofwToken ($credentials->"active_token")
}
}
:global EvofwBootId
:global EvofwTrafficEpoch
:global EvofwLastObservedUptime
:global EvofwRuleCounters
:if ([:typeof $EvofwRuleCounters] != "array") do={ :set EvofwRuleCounters [:toarray ""] }
:global EvofwAppliedRevision
:if ([:typeof $EvofwBootId] = "nothing") do={ :set EvofwBootId [:rndstr length=32]; :set EvofwTrafficEpoch [:rndstr length=32] }
:local stateFile "evofw-telemetry.json"
:if ([:len [/file find name="flash"]] > 0) do={
:local flashType [/file get [find name="flash"] type]
:if ($flashType = "disk" || $flashType = "directory") do={ :set stateFile "flash/evofw-telemetry.json" }
}
:local telemetryState {"next_sequence"=0;"pending"=""}
:if ([:len [/file find name=$stateFile]] > 0) do={
# Corrupt state stops sync; never reset a persisted sequence silently.
:set telemetryState [:deserialize [/file get [find name=$stateFile] contents] from=json options=json.no-string-conversion]
}
:local sendPending do={
:global EvofwCpUrl
:global EvofwToken
:global EvofwSequenceFloor
:set EvofwSequenceFloor -1
:local state $state
:local body ($state->"pending")
:if ([:len $body] = 0) do={ :return true }
:local expected [:deserialize $body from=json options=json.no-string-conversion]
:local delivered false
:do {
:local result [/tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$body output=user as-value duration=1m]
:if (($result->"status") = "finished") do={
:local ack [:deserialize ($result->"data") from=json options=json.no-string-conversion]
:if (($ack->"ok") = true && ($ack->"report_id") = ($expected->"report_id") && ($ack->"sequence") = ($expected->"sequence") && (($ack->"accepted") = true || ($ack->"duplicate") = true || ($ack->"reason") = "stale_sequence")) do={ :set delivered true }
:if ($delivered && ($ack->"reason") = "stale_sequence") do={
:local floor ($ack->"sequence_floor")
:if ([:typeof $floor] != "num" || $floor < ($expected->"sequence") || $floor >= 9007199254740991) do={ :return false }
:set EvofwSequenceFloor $floor
}
}
} on-error={ :set delivered false }
:return $delivered
}
:local resyncSequence do={
:global EvofwSequenceFloor
:global EvofwTrafficEpoch
:local state $state
:if ([:typeof $EvofwSequenceFloor] = "num" && $EvofwSequenceFloor >= ($state->"next_sequence")) do={
:set ($state->"next_sequence") ($EvofwSequenceFloor + 1)
:set ($state->"coverage_unknown") true
:set EvofwTrafficEpoch [:rndstr length=32]
}
:return $state
}
:if ([:len ($telemetryState->"pending")] > 0) do={
:if ([$sendPending state=$telemetryState]) do={
:set ($telemetryState->"pending") ""
:set telemetryState [$resyncSequence state=$telemetryState]
/file set [find name=$stateFile] contents=[:serialize $telemetryState to=json options=json.no-string-conversion]
} else={ :log warning "evofw: pending telemetry ACK not confirmed; retry next sync"; :return }
}
:if ([:typeof ($telemetryState->"next_sequence")] != "num" || ($telemetryState->"next_sequence") < 0 || ($telemetryState->"next_sequence") >= 9007199254740991) do={ :error "invalid/exhausted telemetry sequence" }
:global EvofwRenderedHash
:global EvofwRenderedRevision
:global EvofwRenderedIpv6
:global EvofwAppliedIpv6
:global EvofwApplyErrorCode
:set EvofwApplyErrorCode "apply_failed"
:local readCounters do={
:global EvofwAppliedIpv6
:global EvofwRuleCounters
:global EvofwTrafficEpoch
:local observed [:toarray ""]
:local dropped 0
:local accepted 0
:local valid true
:do {
:local id [/ip firewall filter find comment=evofw-deny-drop-input]
:if ([:len $id] != 1 || [/ip firewall filter get $id chain] != "input" || [/ip firewall filter get $id action] != "drop") do={ :error "counter rule unavailable" }
:local n [/ip firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ip:evofw-deny-drop-input") $n
:if ($dropped > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set dropped ($dropped + $n)
:local id [/ip firewall filter find comment=evofw-deny-drop-forward]
:if ([:len $id] != 1 || [/ip firewall filter get $id chain] != "forward" || [/ip firewall filter get $id action] != "drop") do={ :error "counter rule unavailable" }
:local n [/ip firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ip:evofw-deny-drop-forward") $n
:if ($dropped > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set dropped ($dropped + $n)
:local id [/ip firewall filter find comment=evofw-default-drop-forward]
:if ([:len $id] != 1 || [/ip firewall filter get $id chain] != "forward" || [/ip firewall filter get $id action] != "drop") do={ :error "counter rule unavailable" }
:local n [/ip firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ip:evofw-default-drop-forward") $n
:if ($dropped > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set dropped ($dropped + $n)
:local id [/ip firewall filter find comment=evofw-allow-accept-forward]
:if ([:len $id] != 1 || [/ip firewall filter get $id chain] != "forward" || [/ip firewall filter get $id action] != "accept") do={ :error "counter rule unavailable" }
:local n [/ip firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ip:evofw-allow-accept-forward") $n
:if ($accepted > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set accepted ($accepted + $n)
} on-error={ :set valid false }
:if ($EvofwAppliedIpv6 = true) do={
:do {
:local id [/ipv6 firewall filter find comment=evofw-deny-drop-input]
:if ([:len $id] != 1 || [/ipv6 firewall filter get $id chain] != "input" || [/ipv6 firewall filter get $id action] != "drop") do={ :error "counter rule unavailable" }
:local n [/ipv6 firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ipv6:evofw-deny-drop-input") $n
:if ($dropped > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set dropped ($dropped + $n)
:local id [/ipv6 firewall filter find comment=evofw-deny-drop-forward]
:if ([:len $id] != 1 || [/ipv6 firewall filter get $id chain] != "forward" || [/ipv6 firewall filter get $id action] != "drop") do={ :error "counter rule unavailable" }
:local n [/ipv6 firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ipv6:evofw-deny-drop-forward") $n
:if ($dropped > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set dropped ($dropped + $n)
:local id [/ipv6 firewall filter find comment=evofw-default-drop-forward]
:if ([:len $id] != 1 || [/ipv6 firewall filter get $id chain] != "forward" || [/ipv6 firewall filter get $id action] != "drop") do={ :error "counter rule unavailable" }
:local n [/ipv6 firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ipv6:evofw-default-drop-forward") $n
:if ($dropped > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set dropped ($dropped + $n)
:local id [/ipv6 firewall filter find comment=evofw-allow-accept-forward]
:if ([:len $id] != 1 || [/ipv6 firewall filter get $id chain] != "forward" || [/ipv6 firewall filter get $id action] != "accept") do={ :error "counter rule unavailable" }
:local n [/ipv6 firewall filter get $id packets]
:if ($n < 0 || $n > 9007199254740991) do={ :error "unsafe counter" }
:set ($observed->"ipv6:evofw-allow-accept-forward") $n
:if ($accepted > (9007199254740991 - $n)) do={ :error "unsafe total" }
:set accepted ($accepted + $n)
} on-error={ :set valid false }
}
:if (!$valid) do={ :return {"valid"=false} }
# Commit complete observations only: omissions must not erase reset baselines.
:local reset false
:foreach key,value in=$observed do={
:local previous ($EvofwRuleCounters->$key)
:if ([:typeof $previous] = "num" && $value < $previous) do={ :set reset true }
}
:if ($reset) do={ :set EvofwTrafficEpoch [:rndstr length=32] }
:set EvofwRuleCounters $observed
:return {"valid"=true;"reset"=$reset;"packets_dropped"=$dropped;"packets_accepted"=$accepted}
}
:local syncOk 0
:do {
:local result [/tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken . ",Accept: application/json") output=user as-value]
:if (($result->"status") != "finished") do={
:log warning ("evofw: policy fetch status=" . ($result->"status"))
:error "policy fetch failed"
:local jsonFile "evofw-policy-next.json"
:local result [/tool fetch url=($EvofwCpUrl . "/v1/agent/policy?transport=routeros") http-header-field=("Authorization: Bearer " . $EvofwToken . ",Accept: application/json,Range: bytes=0-8388608") dst-path=$jsonFile as-value duration=1m]
:if (($result->"status") != "finished") do={ :error "policy fetch failed" }
:local fileSize [/file get [find name=$jsonFile] size]
:if ([:typeof $fileSize] != "num" || $fileSize < 2 || $fileSize > 8388608) do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "policy download budget exceeded" }
:local raw ""
:local offset 0
:while ($offset < $fileSize) do={
:local chunk ([/file read file=$jsonFile offset=$offset chunk-size=32768 as-value]->"data")
:if ([:len $chunk] = 0) do={ :error "policy file truncated" }
:set raw ($raw . $chunk)
:set offset ($offset + [:len $chunk])
:if ($offset > 8388608) do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "policy byte budget exceeded" }
}
:local p [:deserialize $raw from=json options=json.no-string-conversion]
:if ([:typeof ($p->"error")] = "array") do={
:if ((($p->"error")->"code") = "POLICY_BUDGET_EXCEEDED") do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "controller policy budget exceeded" }
:error "unexpected policy error envelope"
}
:local raw ($result->"data")
:if ([:len $raw] < 10) do={ :error "empty policy body" }
:local p [:deserialize $raw from=json]
:local hash ($p->"hash")
:if ([:typeof $hash] = "nothing" || [:len $hash] = 0) do={ :error "policy missing hash (pending?)" }
:if ($hash = $EvofwLastHash) do={
:log info ("evofw: unchanged " . $hash)
:if ([:typeof $hash] != "str" || [:len $hash] != 71 || !($hash~"^sha256:[0-9a-f]+$")) do={ :error "invalid policy hash" }
:local deny ($p->"deny_cidrs")
:local allow ($p->"allow_cidrs")
:if ([:typeof $deny] != "array" || [:typeof $allow] != "array") do={ :error "invalid policy network arrays" }
:local da ($p->"default_action")
:if ($da != "accept" && $da != "drop") do={ :error "invalid default action" }
:local rawCount ([:len $deny] + [:len $allow])
:local portRules ($p->"port_rules")
:if ([:typeof $portRules] != "array") do={ :error "invalid policy port rules" }
:if ([:len $portRules] > 1000) do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "port rule budget exceeded" }
:foreach rule in=$portRules do={ :set rawCount ($rawCount + [:len ($rule->"src_cidrs")]) }
:if ($rawCount > 200000) do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "raw network membership budget exceeded" }
:local countDistinct do={
:local seen [:toarray ""]
:local count 0
:foreach value in=$values do={
:if ([:typeof $value] != "str" || [:len $value] > 128) do={ :error "invalid network text" }
:if ([:typeof ($seen->$value)] != "bool") do={ :set ($seen->$value) true; :set count ($count + 1) }
}
:return $count
}
:local effectiveCount ([$countDistinct values=$deny] + [$countDistinct values=$allow])
:foreach rule in=$portRules do={ :set effectiveCount ($effectiveCount + [$countDistinct values=($rule->"src_cidrs")]) }
:if ($effectiveCount > 50000) do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "effective network membership budget exceeded" }
:if ([:len ($p->"port_rules")] > 0) do={ :set EvofwApplyErrorCode "UNSUPPORTED_CAPABILITY"; :error "Port ACL is unsupported on MikroTik" }
:local v6Enabled false
:do { :set v6Enabled (![/ipv6 settings get disable-ipv6]) } on-error={ :set v6Enabled false }
:local needs6 false
:foreach a in=$deny do={ :if ([:typeof [:find $a ":"]] != "nil") do={ :set needs6 true } }
:foreach a in=$allow do={ :if ([:typeof [:find $a ":"]] != "nil") do={ :set needs6 true } }
:if ($needs6 && !$v6Enabled) do={ :set EvofwApplyErrorCode "UNSUPPORTED_CAPABILITY"; :error "IPv6 enforcement unavailable" }
:local drift 0
:do {
:local rule [/ip firewall filter find comment=evofw-deny-drop-input]
:if ([:len $rule] != 1 || [/ip firewall filter get $rule chain] != "input" || [/ip firewall filter get $rule action] != "drop" || [/ip firewall filter get $rule disabled] != false) do={ :error "owned filter chain/action drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ip firewall filter print as-value where comment=[/ip firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local rule [/ip firewall filter find comment=evofw-deny-drop-forward]
:if ([:len $rule] != 1 || [/ip firewall filter get $rule chain] != "forward" || [/ip firewall filter get $rule action] != "drop" || [/ip firewall filter get $rule disabled] != false) do={ :error "owned filter chain/action drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ip firewall filter print as-value where comment=[/ip firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local rule [/ip firewall filter find comment=evofw-allow-accept-forward]
:if ([:len $rule] != 1 || [/ip firewall filter get $rule chain] != "forward" || [/ip firewall filter get $rule action] != "accept" || [/ip firewall filter get $rule disabled] != false) do={ :error "owned filter chain/action drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ip firewall filter print as-value where comment=[/ip firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local rule [/ip firewall filter find comment=evofw-default-drop-forward]
:if ([:len $rule] != 1 || [/ip firewall filter get $rule chain] != "forward" || [/ip firewall filter get $rule action] != "drop" || [/ip firewall filter get $rule disabled] != ($da = "accept")) do={ :error "owned filter chain/action drift" }
:if ([/ip firewall filter get $rule src-address-list] != "") do={ :error "owned default matcher drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ip firewall filter print as-value where comment=[/ip firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local ordered [/ip firewall filter find where chain=input and dynamic=no]
:if (($ordered->0) != ([/ip firewall filter find comment=evofw-deny-drop-input]->0)) do={ :error "owned filter order drift" }
:local ordered [/ip firewall filter find where chain=forward and dynamic=no]
:if (($ordered->0) != ([/ip firewall filter find comment=evofw-deny-drop-forward]->0)) do={ :error "owned filter order drift" }
:if (($ordered->1) != ([/ip firewall filter find comment=evofw-allow-accept-forward]->0)) do={ :error "owned filter order drift" }
:if (($ordered->2) != ([/ip firewall filter find comment=evofw-default-drop-forward]->0)) do={ :error "owned filter order drift" }
:local denyList [/ip firewall filter get [find comment=evofw-deny-drop-input] src-address-list]
:local forwardList [/ip firewall filter get [find comment=evofw-deny-drop-forward] src-address-list]
:local allowList [/ip firewall filter get [find comment=evofw-allow-accept-forward] src-address-list]
:if ($denyList != $forwardList || !($denyList~"^EVOFW_DENY_") || !($allowList~"^EVOFW_ALLOW_")) do={ :error "owned source bank drift" }
:local expectedCount 0
:foreach a in=$deny do={ :if ([:typeof [:find $a ":"]] = "nil") do={
:set expectedCount ($expectedCount + 1)
:if ([:len [/ip firewall address-list find list=$denyList address=$a]] != 1) do={ :error "owned list membership drift" }
} }
:if ([:len [/ip firewall address-list find list=$denyList]] != $expectedCount) do={ :error "owned list size drift" }
:local expectedCount 0
:foreach a in=$allow do={ :if ([:typeof [:find $a ":"]] = "nil") do={
:set expectedCount ($expectedCount + 1)
:if ([:len [/ip firewall address-list find list=$allowList address=$a]] != 1) do={ :error "owned list membership drift" }
} }
:if ([:len [/ip firewall address-list find list=$allowList]] != $expectedCount) do={ :error "owned list size drift" }
} on-error={ :set drift 1 }
:if ($v6Enabled) do={
:do {
:local rule [/ipv6 firewall filter find comment=evofw-deny-drop-input]
:if ([:len $rule] != 1 || [/ipv6 firewall filter get $rule chain] != "input" || [/ipv6 firewall filter get $rule action] != "drop" || [/ipv6 firewall filter get $rule disabled] != false) do={ :error "owned filter chain/action drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ipv6 firewall filter print as-value where comment=[/ipv6 firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local rule [/ipv6 firewall filter find comment=evofw-deny-drop-forward]
:if ([:len $rule] != 1 || [/ipv6 firewall filter get $rule chain] != "forward" || [/ipv6 firewall filter get $rule action] != "drop" || [/ipv6 firewall filter get $rule disabled] != false) do={ :error "owned filter chain/action drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ipv6 firewall filter print as-value where comment=[/ipv6 firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local rule [/ipv6 firewall filter find comment=evofw-allow-accept-forward]
:if ([:len $rule] != 1 || [/ipv6 firewall filter get $rule chain] != "forward" || [/ipv6 firewall filter get $rule action] != "accept" || [/ipv6 firewall filter get $rule disabled] != false) do={ :error "owned filter chain/action drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ipv6 firewall filter print as-value where comment=[/ipv6 firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local rule [/ipv6 firewall filter find comment=evofw-default-drop-forward]
:if ([:len $rule] != 1 || [/ipv6 firewall filter get $rule chain] != "forward" || [/ipv6 firewall filter get $rule action] != "drop" || [/ipv6 firewall filter get $rule disabled] != ($da = "accept")) do={ :error "owned filter chain/action drift" }
:if ([/ipv6 firewall filter get $rule src-address-list] != "") do={ :error "owned default matcher drift" }
:local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}
:foreach row in=[/ipv6 firewall filter print as-value where comment=[/ipv6 firewall filter get $rule comment]] do={
:if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }
:foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "owned matcher drift" } }
}
:local ordered [/ipv6 firewall filter find where chain=input and dynamic=no]
:if (($ordered->0) != ([/ipv6 firewall filter find comment=evofw-deny-drop-input]->0)) do={ :error "owned filter order drift" }
:local ordered [/ipv6 firewall filter find where chain=forward and dynamic=no]
:if (($ordered->0) != ([/ipv6 firewall filter find comment=evofw-deny-drop-forward]->0)) do={ :error "owned filter order drift" }
:if (($ordered->1) != ([/ipv6 firewall filter find comment=evofw-allow-accept-forward]->0)) do={ :error "owned filter order drift" }
:if (($ordered->2) != ([/ipv6 firewall filter find comment=evofw-default-drop-forward]->0)) do={ :error "owned filter order drift" }
:local denyList [/ipv6 firewall filter get [find comment=evofw-deny-drop-input] src-address-list]
:local forwardList [/ipv6 firewall filter get [find comment=evofw-deny-drop-forward] src-address-list]
:local allowList [/ipv6 firewall filter get [find comment=evofw-allow-accept-forward] src-address-list]
:if ($denyList != $forwardList || !($denyList~"^EVOFW_DENY_") || !($allowList~"^EVOFW_ALLOW_")) do={ :error "owned source bank drift" }
:local expectedCount 0
:foreach a in=$deny do={ :if ([:typeof [:find $a ":"]] != "nil") do={
:set expectedCount ($expectedCount + 1)
:if ([:len [/ipv6 firewall address-list find list=$denyList address=$a]] != 1) do={ :error "owned list membership drift" }
} }
:if ([:len [/ipv6 firewall address-list find list=$denyList]] != $expectedCount) do={ :error "owned list size drift" }
:local expectedCount 0
:foreach a in=$allow do={ :if ([:typeof [:find $a ":"]] != "nil") do={
:set expectedCount ($expectedCount + 1)
:if ([:len [/ipv6 firewall address-list find list=$allowList address=$a]] != 1) do={ :error "owned list membership drift" }
} }
:if ([:len [/ipv6 firewall address-list find list=$allowList]] != $expectedCount) do={ :error "owned list size drift" }
} on-error={ :set drift 1 }
}
:if ($hash = $EvofwLastHash && $drift = 0) do={
:set EvofwAppliedRevision ($p->"generation")
:set EvofwAppliedIpv6 $v6Enabled
:log info ("evofw: unchanged; both-family kernel verified " . $hash)
:set syncOk 1
} else={
:local deny ($p->"deny_cidrs")
:local allow ($p->"allow_cidrs")
:do { /ip firewall address-list remove [find list=EVOFW_DENY] } on-error={}
:do { /ip firewall address-list remove [find list=EVOFW_ALLOW] } on-error={}
:foreach a in=$deny do={
:do { /ip firewall address-list add list=EVOFW_DENY address=$a comment=evofw } on-error={}
# ACK a durable final snapshot before the compiler resets rule counters.
:if ([:len $EvofwLastHash] = 71) do={
:local finalCounters [$readCounters]
:local finalSeq ($telemetryState->"next_sequence")
:local finalUptime [:tonsec [/system resource get uptime]]
:local finalInterval 60000
:if ([:typeof $EvofwLastObservedUptime] != "nothing" && $finalUptime > $EvofwLastObservedUptime) do={ :set finalInterval (($finalUptime - $EvofwLastObservedUptime) / 1000000) }
:if ($finalInterval < 1) do={ :set finalInterval 1 }
:local observed ([/system clock get date] . "T" . [/system clock get time] . [/system clock get gmt-offset])
:local final {"telemetry_version"=2;"report_id"=[:rndstr length=32];"sequence"=$finalSeq;"boot_id"=$EvofwBootId;"observed_at"=$observed;"interval_ms"=$finalInterval;"counter_epoch"={"traffic"=$EvofwTrafficEpoch};"agent_version"="rsc/10";"source"="mikrotik";"status"="ok";"kernel_method"="address-list";"applied_hash"=$EvofwLastHash;"capabilities"={"telemetry-v2"};"quality"={"partial"=true;"truncated"=false;"unsupported"={"final_snapshot_before_apply";"file_commit_not_powerloss_atomic"};"lost_reports"=0}}
:if (($finalCounters->"reset") = true) do={ :set (($final->"quality")->"unsupported") {"final_snapshot_before_apply";"file_commit_not_powerloss_atomic";"counter_reset_coverage_unknown"} }
:if ($drift = 1) do={ :set ($final->"status") "error"; :set ($final->"apply_error") {"code"="KERNEL_DRIFT";"message"="Owned rule drift detected; repair pending";"stage"="verify";"retryable"=true} }
:if (($finalCounters->"valid") = true) do={ :set ($final->"packets_dropped") ($finalCounters->"packets_dropped"); :set ($final->"packets_accepted") ($finalCounters->"packets_accepted") }
:if ([:typeof $EvofwAppliedRevision] = "num") do={ :set ($final->"applied_revision") $EvofwAppliedRevision }
:set ($telemetryState->"next_sequence") ($finalSeq + 1)
:set ($telemetryState->"pending") [:serialize $final to=json options=json.no-string-conversion]
:if ([:len [/file find name=$stateFile]] = 0) do={ /file add name=$stateFile type=file }
/file set [find name=$stateFile] contents=[:serialize $telemetryState to=json options=json.no-string-conversion]
:set EvofwLastObservedUptime $finalUptime
:if ([$sendPending state=$telemetryState]) do={
:set ($telemetryState->"pending") ""
:set telemetryState [$resyncSequence state=$telemetryState]
/file set [find name=$stateFile] contents=[:serialize $telemetryState to=json options=json.no-string-conversion]
} else={ :error "final telemetry ACK pending; defer counter-resetting apply" }
}
:foreach a in=$allow do={
:do { /ip firewall address-list add list=EVOFW_ALLOW address=$a comment=evofw } on-error={}
}
:local da ($p->"default_action")
:do { /ip firewall filter set [find comment=evofw-deny-hit-input] disabled=no } on-error={}
:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}
:do { /ip firewall filter set [find comment=evofw-deny-hit-forward] disabled=no } on-error={}
:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}
:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}
:if ($da = "drop") do={
:do { /ip firewall filter set [find comment=evofw-default-drop-forward] disabled=no } on-error={}
} else={
:do { /ip firewall filter set [find comment=evofw-default-drop-forward] disabled=yes } on-error={}
}
:set EvofwLastHash $hash
:log info ("evofw: applied " . $hash)
:local file "evofw-policy-next.rsc"
:set EvofwRenderedHash ""
:set EvofwRenderedRevision -1
:local compiled [/tool fetch url=($EvofwCpUrl . "/v1/agent/policy.rsc") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=$file as-value duration=1m]
:if (($compiled->"status") != "finished") do={ :error "compiled policy download failed" }
:local compiledSize [/file get [find name=$file] size]
:if ([:typeof $compiledSize] != "num" || $compiledSize > 8388608) do={ :set EvofwApplyErrorCode "POLICY_BUDGET_EXCEEDED"; :error "compiled policy byte budget exceeded" }
/import file-name=$file
# The second endpoint can render a newer policy. ACK its actual metadata,
# not the earlier JSON generation; blank metadata means import failed.
:if ([:len $EvofwRenderedHash] != 71 || [:typeof $EvofwRenderedRevision] != "num" || $EvofwRenderedRevision < 0) do={ :error "compiled policy did not complete" }
:set EvofwLastHash $EvofwRenderedHash
:set EvofwAppliedRevision $EvofwRenderedRevision
:set EvofwAppliedIpv6 $EvofwRenderedIpv6
:set EvofwTrafficEpoch [:rndstr length=32]
:set EvofwRuleCounters [:toarray ""]
:set syncOk 1
}
} on-error={
:log warning "evofw: policy sync failed (pending approval, network, or JSON)"
:log warning "evofw: policy sync failed; last successful hash retained"
}
:if ([:len ($telemetryState->"pending")] > 0) do={ :return }
# One immutable pending report is saved before sending. Address-list presence
# is never presented as measured IP packets. No separate post-ACK heartbeat.
:local status "error"
:if ($syncOk = 1) do={ :set status "ok" }
:local seq ($telemetryState->"next_sequence")
:local uptime [:tonsec [/system resource get uptime]]
:local interval 60000
:if ([:typeof $EvofwLastObservedUptime] != "nothing" && $uptime > $EvofwLastObservedUptime) do={ :set interval (($uptime - $EvofwLastObservedUptime) / 1000000) }
:if ($interval < 1) do={ :set interval 1 }
:local observed ([/system clock get date] . "T" . [/system clock get time] . [/system clock get gmt-offset])
:local unsupported {"ip_counters_presence_only";"port_counters_unavailable";"host_firewall_unavailable";"file_commit_not_powerloss_atomic"}
:local report {"telemetry_version"=2;"report_id"=[:rndstr length=32];"sequence"=$seq;"boot_id"=$EvofwBootId;"observed_at"=$observed;"interval_ms"=$interval;"agent_version"="rsc/10";"status"=$status;"kernel_method"="address-list";"source"="mikrotik"}
:local capabilities {"telemetry-v2";"policy-ipv4";"mikrotik-staged-apply"}
:if ($syncOk = 1) do={
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
:local cnt ($denyCnt + $allowCnt)
# Cumulative counters from permanent drop/accept rules (not hit passthrough).
:local dropped 0
:local accepted 0
:do {
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-drop-"] do={
:set dropped ($dropped + [/ip firewall filter get $i packets])
}
} on-error={}
:do {
:foreach i in=[/ip firewall filter find where comment=evofw-default-drop-forward] do={
:set dropped ($dropped + [/ip firewall filter get $i packets])
}
} on-error={}
:do {
:foreach i in=[/ip firewall filter find where comment=evofw-allow-accept-forward] do={
:set accepted ($accepted + [/ip firewall filter get $i packets])
}
} on-error={}
# EVOFW_HITS: real src /32 that matched deny (timeout 1h). Do not wipe on policy rebuild.
:local hits "["
:local hitN 0
:do {
:foreach i in=[/ip firewall address-list find list=EVOFW_HITS] do={
:if ($hitN < 200) do={
:local a [/ip firewall address-list get $i address]
:if ([:len $a] > 0) do={
:if ($hitN > 0) do={ :set hits ($hits . ",") }
:set hits ($hits . "{\"ip\":\"" . $a . "\",\"packets\":1}")
:set hitN ($hitN + 1)
}
}
}
} on-error={}
:set hits ($hits . "]")
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\",\"ip_hits\":" . $hits . "}")
:do {
/tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$report keep-result=no
} on-error={}
:do {
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
} on-error={}
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " hits=" . $hitN . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
:local denyCnt [:len [/ip firewall address-list find list=[/ip firewall filter get [find comment=evofw-deny-drop-input] src-address-list]]]
:local allowCnt [:len [/ip firewall address-list find list=[/ip firewall filter get [find comment=evofw-allow-accept-forward] src-address-list]]]
:set ($report->"prefix_count") ($denyCnt + $allowCnt)
:local counters [$readCounters]
:if (($counters->"valid") = true) do={
:local dropped ($counters->"packets_dropped")
:local accepted ($counters->"packets_accepted")
:set ($report->"packets_dropped") $dropped
:set ($report->"packets_accepted") $accepted
:set capabilities ($capabilities,{"traffic-counters"})
:if (($counters->"reset") = true) do={ :set unsupported ($unsupported,{"counter_reset_coverage_unknown"}) }
} else={ :set unsupported ($unsupported,{"traffic_counters_unavailable"}) }
:if ($EvofwAppliedIpv6 = true) do={
:set capabilities ($capabilities,{"policy-ipv6";"traffic-counters-ipv6"})
:local deny6 [:len [/ipv6 firewall address-list find list=[/ipv6 firewall filter get [find comment=evofw-deny-drop-input] src-address-list]]]
:local allow6 [:len [/ipv6 firewall address-list find list=[/ipv6 firewall filter get [find comment=evofw-allow-accept-forward] src-address-list]]]
:set ($report->"prefix_count") (($report->"prefix_count") + $deny6 + $allow6)
}
:if ([:len $EvofwLastHash] = 71) do={ :set ($report->"applied_hash") $EvofwLastHash }
:if ([:typeof $EvofwAppliedRevision] = "num") do={ :set ($report->"applied_revision") $EvofwAppliedRevision }
} else={
:set ($report->"apply_error") {"code"=$EvofwApplyErrorCode;"message"="Policy validation, capability, import or apply failed; previous hash retained";"stage"="routeros";"retryable"=true}
}
:set ($report->"counter_epoch") {"traffic"=$EvofwTrafficEpoch}
:set ($report->"capabilities") $capabilities
:if (($telemetryState->"coverage_unknown") = true) do={ :set unsupported ($unsupported,{"sequence_rollback_coverage_unknown"}) }
:set ($report->"quality") {"partial"=true;"truncated"=false;"unsupported"=$unsupported;"lost_reports"=0}
:set ($telemetryState->"next_sequence") ($seq + 1)
:set ($telemetryState->"coverage_unknown") false
:set ($telemetryState->"pending") [:serialize $report to=json options=json.no-string-conversion]
:if ([:len [/file find name=$stateFile]] = 0) do={ /file add name=$stateFile type=file }
/file set [find name=$stateFile] contents=[:serialize $telemetryState to=json options=json.no-string-conversion]
:set EvofwLastObservedUptime $uptime
:if ([$sendPending state=$telemetryState]) do={
:set ($telemetryState->"pending") ""
:set telemetryState [$resyncSequence state=$telemetryState]
/file set [find name=$stateFile] contents=[:serialize $telemetryState to=json options=json.no-string-conversion]
} else={ :log warning "evofw: saved pending telemetry; retry next sync" }
}
:local bodySrc [/system script get [find name="evofw-sync-body"] source]
+19 -16
View File
@@ -9,6 +9,7 @@ import { AsyncTask, CronJob } from 'toad-scheduler'
import type { AppConfig } from './config.js'
import { loadConfig } from './config.js'
import authPlugin from './plugins/auth.js'
import operationalPlugin from './plugins/operational.js'
import corsPlugin from './plugins/cors.js'
import dbPlugin from './plugins/db.js'
import errorHandlerPlugin from './plugins/error-handler.js'
@@ -16,7 +17,11 @@ import { healthRoutes } from './routes/health.js'
import { controlRoutes } from './routes/control.js'
import { auditRoutes } from './routes/audit.js'
import { agentRoutes } from './routes/agent.js'
import { enrollmentRoutes } from './routes/enrollment.js'
import { policyRolloutsRoutes } from './routes/policy-rollouts.js'
import { webhooksRoutes } from './routes/webhooks.js'
import { refreshAllLists } from './services/lists/refresh.js'
import { runStatsRetention } from './services/analytics.js'
import { repos } from '@evofw/db'
import {
isValidInstallSlug,
@@ -32,6 +37,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
const config = opts.config ?? loadConfig()
const app = Fastify({
trustProxy: config.trustProxy ?? false,
logger: { level: config.logLevel },
genReqId: () => crypto.randomUUID(),
}).withTypeProvider<ZodTypeProvider>()
@@ -66,14 +72,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
}
: false,
})
await app.register(import('@fastify/rate-limit'), {
max: 300,
timeWindow: '1 minute',
})
await app.register(corsPlugin, { config })
await app.register(errorHandlerPlugin)
await app.register(dbPlugin, { config, memory: opts.memory })
await app.register(authPlugin, { config })
await app.register(operationalPlugin, { config, memory: opts.memory })
// Seed enroll_seed into settings if empty
if (!repos.getSetting(app.db, 'enroll_seed')) {
@@ -82,12 +85,15 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(healthRoutes)
await app.register(agentRoutes, { config })
await app.register(enrollmentRoutes, { config })
await app.register(
async (protectedApi) => {
protectedApi.addHook('onRequest', app.requireAuth)
await protectedApi.register(controlRoutes, { config })
await protectedApi.register(auditRoutes)
await protectedApi.register(policyRolloutsRoutes, { config })
await protectedApi.register(webhooksRoutes, { config, memory: opts.memory })
},
{ prefix: '/api/v1' },
)
@@ -102,6 +108,9 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.setNotFoundHandler(async (request, reply) => {
const path = request.url.split('?')[0] ?? ''
if (path === '/api' || path.startsWith('/api/') || path === '/v1' || path.startsWith('/v1/')) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not Found', request_id: request.id } })
}
const segment = path.startsWith('/') ? path.slice(1) : path
if (
request.method === 'GET' &&
@@ -117,7 +126,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
config.publicBaseUrl,
config.enrollSeed,
)
return reply.type(contentType).send(body)
return reply.header('Cache-Control', 'no-store').type(contentType).send(body)
}
}
@@ -149,21 +158,15 @@ export async function buildApp(opts: BuildAppOptions = {}) {
// Raw stats samples grow ~1 row per agent per sync interval; drop old
// ones daily. Lifetime totals live on agents; aggregates are kept.
const retentionAbort = new AbortController()
app.addHook('onClose', async () => { retentionAbort.abort() })
const runRetention = async () => {
const cutoff = new Date(
Date.now() - config.statsRetentionDays * 86_400_000,
).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
if (deleted > 0) {
app.log.info(
{ deleted, cutoff },
`stats retention: raw samples older than ${config.statsRetentionDays}d removed`,
)
}
const deleted = await runStatsRetention(app.db, new Date().toISOString(), 1000, retentionAbort.signal)
if (deleted > 0) app.log.info({ deleted }, 'stats retention completed')
}
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: '17 3 * * *' },
{ cronExpression: '17 * * * *' },
new AsyncTask('stats-retention', runRetention, (err) => {
app.log.warn({ err }, 'stats retention failed')
}),
+15
View File
@@ -1,4 +1,5 @@
import { resolve } from 'node:path'
import { isIP } from 'node:net'
export interface AppConfig {
databaseUrl: string
@@ -16,6 +17,7 @@ export interface AppConfig {
corsOrigins: string[]
secretKey: string | null
statsRetentionDays: number
trustProxy?: false | string[]
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -26,6 +28,18 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
const DEV_JWT_SECRET = 'dev-secret-change-me'
const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me'
export function trustedProxyCidrs(value: string | undefined): false | string[] {
const entries = (value || '').split(',').map(s => s.trim()).filter(Boolean)
for (const entry of entries) {
const [address, mask, extra] = entry.split('/')
const family = isIP(address!)
if (!family || extra !== undefined || (mask !== undefined && (!/^\d+$/.test(mask) || Number(mask) > (family === 4 ? 32 : 128)))) {
throw new Error('TRUST_PROXY_CIDRS must contain explicit IP addresses or CIDRs')
}
}
return entries.length ? entries : false
}
export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === 'production'
const jwtSecret =
@@ -70,6 +84,7 @@ export function loadConfig(): AppConfig {
1,
Math.min(3650, Number(process.env.STATS_RETENTION_DAYS ?? '30') || 30),
),
trustProxy: trustedProxyCidrs(process.env.TRUST_PROXY_CIDRS),
}
// Fail-safe: a production process must not start wide open or with
+22 -4
View File
@@ -8,6 +8,7 @@ import {
import type { AppConfig } from '../config.js'
import { createHash } from 'node:crypto'
import { repos } from '@evofw/db'
import { authenticateTokenExchange } from '../services/enrollment.js'
declare module 'fastify' {
interface FastifyRequest {
@@ -61,6 +62,8 @@ function isAgentPath(url: string): boolean {
path === '/v1/agent/policy' ||
path === '/v1/agent/policy.rsc' ||
path === '/v1/agent/apply-report' ||
path === '/v1/agent/credentials' ||
path === '/v1/agent/token-rotation' ||
path === '/v1/agent/heartbeat'
)
}
@@ -122,12 +125,16 @@ async function authPlugin(
try {
await request.jwtVerify()
} catch {
if (!app.allowFailedAuthAttempt(request.ip)) return reply.code(429).header('Retry-After', '60').send({ error: { code: 'RATE_LIMITED', message: 'Too many authentication failures' } })
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const payload = request.user
if (typeof payload.sub !== 'string' || !payload.sub || payload.sub.length > 256) {
return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Invalid user identity' } })
}
const apps = Array.isArray(payload.apps)
? payload.apps.map(String)
: []
@@ -135,7 +142,7 @@ async function authPlugin(
? payload.permissions.map(String)
: []
if (!apps.includes('fw') && !payload.is_admin) {
if (!apps.includes('fw') && payload.is_admin !== true) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
@@ -149,7 +156,7 @@ async function authPlugin(
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps,
permissions: payload.is_admin
permissions: payload.is_admin === true
? [
'fw:dashboard:read',
'fw:agents:write',
@@ -160,10 +167,10 @@ async function authPlugin(
'fw:audit:read',
]
: permissions,
isAdmin: Boolean(payload.is_admin),
isAdmin: payload.is_admin === true,
}
const required = permissionForRequest(request.method, request.url)
const required = request.routeOptions.config.requiredPermission ?? permissionForRequest(request.method, request.url)
if (
required &&
!request.authUser.isAdmin &&
@@ -185,13 +192,24 @@ async function authPlugin(
if (isAgentPath(request.url)) {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
if (!app.allowFailedAuthAttempt(request.ip)) return reply.code(429).header('Retry-After', '60').send({ error: { code: 'RATE_LIMITED', message: 'Too many authentication failures' } })
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Agent token required' },
})
}
const token = auth.slice('Bearer '.length).trim()
if (request.method === 'POST' && request.url.split('?')[0] === '/v1/agent/token-rotation') {
const agentId = authenticateTokenExchange(app.db, app.sqlite, token)
if (!agentId) {
if (!app.allowFailedAuthAttempt(request.ip)) return reply.code(429).header('Retry-After', '60').send({ error: { code: 'RATE_LIMITED', message: 'Too many authentication failures' } })
return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Invalid agent token' } })
}
request.agentId = agentId
return
}
const agent = repos.getAgentByTokenHash(app.db, hashToken(token))
if (!agent || agent.status !== 'approved') {
if (!app.allowFailedAuthAttempt(request.ip)) return reply.code(429).header('Retry-After', '60').send({ error: { code: 'RATE_LIMITED', message: 'Too many authentication failures' } })
return reply.code(agent?.status === 'pending' ? 403 : 401).send({
error: {
code: agent?.status === 'pending' ? 'FORBIDDEN' : 'UNAUTHORIZED',
+2 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import { ZodError } from 'zod'
import { PolicyPinnedError } from '@evofw/db'
export class AppError extends Error {
constructor(
@@ -15,7 +16,7 @@ export class AppError extends Error {
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) {
if (err instanceof AppError || err instanceof PolicyPinnedError) {
return reply.code(err.statusCode).send({
error: {
code: err.code,
+105
View File
@@ -0,0 +1,105 @@
import fp from 'fastify-plugin'
import type { FastifyPluginAsync } from 'fastify'
import { hasPermission, permissionForRequest } from '@evofw/shared'
import { stat, statfs } from 'node:fs/promises'
import { dirname } from 'node:path'
import type { AppConfig } from '../config.js'
import { AuditOutbox, AUDIT_QUEUE_LIMIT } from '../services/audit-outbox.js'
type PermissionPolicy = 'public' | 'agent' | 'protected' | 'section-filtered'
export interface RouteSecurity { method: string; url: string; policy: PermissionPolicy; permission: string | null }
declare module 'fastify' {
interface FastifyContextConfig { permissionPolicy?: PermissionPolicy; requiredPermission?: string | null }
interface FastifyInstance { auditOutbox: AuditOutbox; routeSecurity: RouteSecurity[]; allowFailedAuthAttempt: (ip: string) => boolean }
}
export function routeSecurityFor(method: string, url: string): RouteSecurity {
const path = url.split('?')[0]!
if (['/v1/agent/policy', '/v1/agent/policy.rsc', '/v1/agent/apply-report', '/v1/agent/heartbeat', '/v1/agent/credentials', '/v1/agent/token-rotation'].includes(path)) return { method, url, policy: 'agent', permission: null }
const read = ['GET', 'HEAD'].includes(method)
const publicAgent = (path === '/v1/agent/enroll' && method === 'POST') || (read && ['/v1/agent/install.sh', '/v1/agent/uninstall.sh', '/v1/agent/sync-script', '/v1/agent/mikrotik-install.rsc'].includes(path))
if (publicAgent || (read && ['/api/v1/auth/config', '/api/auth/config'].includes(path))) return { method, url, policy: 'public', permission: null }
if (path === '/v1' || path.startsWith('/v1/')) throw new Error(`Agent route lacks authentication mapping: ${method} ${path}`)
if (path !== '/api' && !path.startsWith('/api/')) return { method, url, policy: 'public', permission: null }
if (path === '/api/v1/ui/summary' && ['GET', 'HEAD'].includes(method)) return { method, url, policy: 'section-filtered', permission: null }
const permission = permissionForRequest(method, path)
if (!permission) throw new Error(`Protected route lacks permission mapping: ${method} ${path}`)
return { method, url, policy: 'protected', permission }
}
export class IdentityLimiter {
private buckets = new Map<string, { start: number; count: number }>()
rejected = 0
constructor(readonly capacity = 10_000) {}
take(key: string, limit: number, now = Date.now()): boolean {
let value = this.buckets.get(key)
if (!value || now - value.start >= 60_000) {
if (!value && this.buckets.size >= this.capacity) {
for (const [id, bucket] of this.buckets) if (now - bucket.start >= 60_000) this.buckets.delete(id)
if (this.buckets.size >= this.capacity) { this.rejected++; return false }
}
value = { start: now, count: 0 }; this.buckets.set(key, value)
}
if (value.count >= limit) { this.rejected++; return false }
value.count++; return true
}
stats() { return { buckets: this.buckets.size, capacity: this.capacity, rejected: this.rejected } }
available(key: string, limit: number, now = Date.now()): boolean {
const bucket = this.buckets.get(key)
return !bucket || now - bucket.start >= 60_000 || bucket.count < limit
}
}
const operationalPlugin: FastifyPluginAsync<{ config: AppConfig; memory?: boolean }> = async (app, { config, memory }) => {
const limiter = new IdentityLimiter()
const failures = new IdentityLimiter()
const queue = new AuditOutbox(app.sqlite, config)
app.decorate('auditOutbox', queue)
app.decorate('routeSecurity', [])
app.decorate('allowFailedAuthAttempt', (ip: string) => failures.available(ip, 60))
app.addHook('onResponse', async (request, reply) => {
if (reply.statusCode === 401 || reply.statusCode === 403) failures.take(request.ip, 60)
})
app.addHook('onRoute', route => {
for (const method of Array.isArray(route.method) ? route.method : [route.method]) {
const security = routeSecurityFor(method, route.url)
app.routeSecurity.push(security)
route.config = { ...route.config, permissionPolicy: security.policy, requiredPermission: security.permission }
}
})
app.addHook('preHandler', async (request, reply) => {
const security = request.routeOptions.config
if (security.permissionPolicy === 'agent' && !request.agentId) return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Agent token required' } })
if (security.permissionPolicy === 'protected' || security.permissionPolicy === 'section-filtered') {
if (!request.authUser) await app.requireAuth(request, reply)
if (reply.sent) return reply
if (security.requiredPermission && !request.authUser?.isAdmin && !hasPermission(request.authUser?.permissions || [], security.requiredPermission)) {
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Недостаточно прав', request_id: request.id } })
}
}
const path = request.url.split('?')[0]!
if (path === '/health' || path === '/ready' || (!path.startsWith('/api/') && !path.startsWith('/v1/'))) return
const enrollment = path === '/v1/agent/enroll'
const key = enrollment ? `enroll:${request.ip}` : request.agentId ? `agent:${request.agentId}` : request.authUser ? `user:${request.authUser.id}` : `public:${request.ip}`
const limit = enrollment ? 20 : request.agentId ? 120 : request.authUser ? 600 : 120
if (!limiter.take(key, limit)) return reply.code(429).header('Retry-After', '60').send({ error: { code: 'RATE_LIMITED', message: 'Too many requests', request_id: request.id } })
if (queue.enabled && security.permissionPolicy === 'protected' && !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && queue.size() >= AUDIT_QUEUE_LIMIT) {
return reply.code(503).send({ error: { code: 'AUDIT_BACKPRESSURE', message: 'Audit delivery queue is full', request_id: request.id } })
}
})
app.get('/api/v1/diagnostics', async () => {
const path = config.databaseUrl.replace(/^sqlite:/, '')
const fileSize = async (name: string) => { try { return (await stat(name)).size } catch { return null } }
let disk: { free_bytes: number; total_bytes: number } | null = null
if (!memory) try { const data = await statfs(dirname(path)); disk = { free_bytes: data.bavail * data.bsize, total_bytes: data.blocks * data.bsize } } catch { /* exposed as unavailable */ }
return {
database: { memory: !!memory, db_bytes: memory ? null : await fileSize(path), wal_bytes: memory ? null : await fileSize(`${path}-wal`), disk },
audit_outbox: queue.stats(), limiter: limiter.stats(), auth_failures: failures.stats(),
process: { uptime_seconds: process.uptime(), rss_bytes: process.memoryUsage().rss, heap_used_bytes: process.memoryUsage().heapUsed },
}
})
const timer = memory ? null : setInterval(() => { void queue.flush().catch(() => app.log.warn('audit outbox worker failed')) }, 5000)
timer?.unref()
app.addHook('onClose', async () => { if (timer) clearInterval(timer); await queue.close() })
}
export default fp(operationalPlugin, { name: 'operational', dependencies: ['auth', 'db'] })
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos, type AgentLabelRow } from '@evofw/db'
import { agentLabelBodySchema, patchAgentLabelSchema, putAgentLabelsSchema, previewLabelAssignmentSchema, applyLabelAssignmentSchema, policyPageSchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { AppError } from '../plugins/error-handler.js'
import { auditMutation } from '../services/audit.js'
import { createAgentLabel, patchAgentLabel, putAgentLabels, previewLabelAssignment, applyLabelAssignment } from '../services/labels.js'
export const agentLabelsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (app, { config }) => {
const map = (row: AgentLabelRow) => { const { name_key: _, ...item } = row; return item }
const audit = (req: Parameters<typeof auditMutation>[2], action: string, id: string, details?: Record<string, unknown>) => auditMutation(app, config, req, { action, targetType: 'app_resource', targetId: id, summary: action, details })
app.get('/agent-labels', async (req) => {
const page = policyPageSchema.parse(req.query)
return { items: repos.listAgentLabels(app.db, page.limit, page.offset).map(map), total: repos.countAgentLabels(app.db) }
})
app.post('/agent-labels', async (req, reply) => {
const row = createAgentLabel(app.db, agentLabelBodySchema.parse(req.body))
audit(req, 'agent_label.create', row.id)
return reply.code(201).send(map(row))
})
app.patch<{ Params: { id: string } }>('/agent-labels/:id', async (req) => {
const row = patchAgentLabel(app.db, req.params.id, patchAgentLabelSchema.parse(req.body))
audit(req, 'agent_label.update', row.id)
return map(row)
})
app.delete<{ Params: { id: string } }>('/agent-labels/:id', async (req) => {
if (!repos.deleteAgentLabel(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Label not found', 404)
audit(req, 'agent_label.delete', req.params.id)
return { ok: true }
})
app.get<{ Params: { id: string } }>('/agents/:id/labels', async (req) => {
if (!repos.getAgent(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return { agent_id: req.params.id, label_ids: repos.agentLabelIds(app.db, req.params.id) }
})
app.put<{ Params: { id: string } }>('/agents/:id/labels', async (req) => {
const result = putAgentLabels(app.db, req.params.id, putAgentLabelsSchema.parse(req.body).label_ids)
audit(req, 'agent.labels.update', req.params.id, { label_ids: result.label_ids })
return result
})
app.post('/agent-labels/preview-assignment', async (req) => previewLabelAssignment(app.db, previewLabelAssignmentSchema.parse(req.body)))
app.post('/agent-labels/assign-policy-sets', async (req) => {
const result = applyLabelAssignment(app.db, applyLabelAssignmentSchema.parse(req.body))
audit(req, 'agent_label.assign_policy_sets', result.items[0]!.agent_id, { affected_agent_ids: result.affected_agent_ids })
return result
})
}
+64 -232
View File
@@ -1,13 +1,13 @@
import { readFileSync } from 'node:fs'
import { createHash, timingSafeEqual } from 'node:crypto'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { enrollBodySchema, applyReportBodySchema, hostFwRuleSchema, hostListenerSchema } from '@evofw/shared'
import { applyReportBodySchema, POLICY_BUDGET, POLICY_BUDGET_ERROR, agentPolicyQuerySchema, policyBudgetErrorEnvelopeSchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { hashToken } from '../plugins/auth.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import { evaluateAgentPolicy, policyBudgetExceeded } from '../services/policy/evaluate.js'
import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
import { ingestTelemetry } from '../services/telemetry.js'
import { AppError } from '../plugins/error-handler.js'
import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js'
import { resolveAndRenderInstall } from '../services/install-links.js'
@@ -18,33 +18,8 @@ import {
const scriptsDir = resolveAgentScriptsDir()
/** Constant-time seed check; hash first so lengths always match. */
function seedMatches(presented: string | undefined, expected: string): boolean {
if (!presented) return false
const a = createHash('sha256').update(presented).digest()
const b = createHash('sha256').update(expected).digest()
return timingSafeEqual(a, b)
}
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,
@@ -68,7 +43,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
config.publicBaseUrl,
config.enrollSeed,
)
return reply.type(contentType).send(body)
return reply.header('cache-control', 'no-store').type(contentType).send(body)
},
)
@@ -96,123 +71,59 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
return reply.type('text/plain').send(body)
})
app.post('/v1/agent/enroll', async (req, reply) => {
const seed = req.headers['x-evofw-seed']
const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
if (!seedMatches(String(seed ?? ''), expected)) {
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
}
const body = enrollBodySchema.parse(req.body)
const tokenHash = hashToken(body.token)
const existingByToken = repos.getAgentByTokenHash(app.db, tokenHash)
if (existingByToken) {
throw new AppError('CONFLICT', 'Token already enrolled', 409)
}
if (body.install_link_id) {
const link = repos.getInstallLink(app.db, body.install_link_id)
if (!link) {
throw new AppError('NOT_FOUND', 'Install link not found', 404)
}
if (link.revokedAt) {
throw new AppError('GONE', 'Install link revoked', 410)
}
if (!link.agentId) {
throw new AppError('CONFLICT', 'Install link has no agent', 409)
}
const invited = repos.getAgent(app.db, link.agentId)
if (!invited) {
throw new AppError('NOT_FOUND', 'Agent not found', 404)
}
if (invited.status !== 'invited' && invited.status !== 'pending') {
throw new AppError(
'CONFLICT',
`Agent status is ${invited.status}`,
409,
)
}
const agent = repos.updateAgent(app.db, invited.id, {
name: body.name,
hostname: body.hostname ?? null,
platform: body.platform ?? invited.platform,
tokenPrefix: body.token.slice(0, 12),
tokenHash,
status: 'pending',
clientVersion: body.client_version ?? null,
app.get('/v1/agent/policy', async (req, reply) => {
const query = agentPolicyQuerySchema.parse(req.query)
try {
const agentId = req.agentId!
// Record contact first — empty policy (no rule sets) is a valid online state.
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
return reply.code(201).send({
client_id: agent!.id,
id: agent!.id,
status: agent!.status,
name: agent!.name,
})
}
const id = crypto.randomUUID()
const now = new Date().toISOString()
const agent = repos.insertAgent(app.db, {
id,
name: body.name,
hostname: body.hostname ?? null,
platform: body.platform ?? 'linux',
tokenPrefix: body.token.slice(0, 12),
tokenHash,
status: 'pending',
defaultAction: 'accept',
policyGeneration: 1,
clientVersion: body.client_version ?? null,
settingsJson: '{}',
createdAt: now,
lastSeenAt: now,
lastSeenIp: req.ip,
})
return reply.code(201).send({
client_id: agent!.id,
id: agent!.id,
status: agent!.status,
name: agent!.name,
})
})
app.get('/v1/agent/policy', async (req) => {
const agentId = req.agentId!
// Record contact first — empty policy (no rule sets) is a valid online state.
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
const policy = evaluateAgentPolicy(app.db, agentId)
const scriptSha = getSyncScriptMeta().sha256
return {
generation: policy.generation,
hash: policy.hash,
apply_version: policy.applyVersion,
default_action: policy.defaultAction,
policy_mode: policy.policyMode,
script_sha256: scriptSha,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
port_rules: policy.portRules.map((r) => ({
id: r.id,
action: r.action,
protocol: r.protocol,
port_start: r.portStart,
port_end: r.portEnd,
src_cidrs: r.srcCidrs,
})),
sync_interval_sec: policy.syncIntervalSec,
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
prefixes:
policy.defaultAction === 'accept'
? policy.denyCidrs
: policy.allowCidrs,
total:
policy.defaultAction === 'accept'
? policy.denyCidrs.length
: policy.allowCidrs.length,
const policy = evaluateAgentPolicy(app.db, agentId)
const scriptSha = getSyncScriptMeta().sha256
const response = {
generation: policy.generation,
hash: policy.hash,
apply_version: policy.applyVersion,
default_action: policy.defaultAction,
policy_mode: policy.policyMode,
script_sha256: scriptSha,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
port_rules: policy.portRules.map((r) => ({
id: r.id,
action: r.action,
protocol: r.protocol,
port_start: r.portStart,
port_end: r.portEnd,
src_cidrs: r.srcCidrs,
})),
sync_interval_sec: policy.syncIntervalSec,
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
prefixes:
policy.defaultAction === 'accept'
? policy.denyCidrs
: policy.allowCidrs,
total:
policy.defaultAction === 'accept'
? policy.denyCidrs.length
: policy.allowCidrs.length,
}
const json = JSON.stringify(response)
if (Buffer.byteLength(json) > POLICY_BUDGET.jsonBytes) policyBudgetExceeded('8MiB policy response JSON')
const etag = `"policy-v1-${createHash('sha256').update(json).digest('hex')}"`
repos.updateAgent(app.db, agentId, { desiredHash: policy.hash, desiredHashGeneration: policy.generation, lastPolicyDeliveredAt: new Date().toISOString() })
reply.header('etag', etag).header('cache-control', 'private, no-cache')
if (ifNoneMatchHits(req.headers['if-none-match'], etag)) return reply.code(304).send()
return reply.type('application/json').send(json)
} catch (error) {
if (query.transport === 'routeros' && error instanceof AppError && error.code === POLICY_BUDGET_ERROR) {
reply.removeHeader('etag')
return reply.header('cache-control', 'no-store').code(200).send(policyBudgetErrorEnvelopeSchema.parse({ error: { code: POLICY_BUDGET_ERROR, message: error.message.slice(0, 512) } }))
}
throw error
}
})
@@ -223,98 +134,19 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
lastSeenIp: req.ip,
})
const policy = evaluateAgentPolicy(app.db, agentId)
return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy))
const script = renderMikrotikPolicyRsc(policy)
if (Buffer.byteLength(script) > POLICY_BUDGET.jsonBytes) policyBudgetExceeded('8MiB RouterOS policy response')
repos.updateAgent(app.db, agentId, { desiredHash: policy.hash, desiredHashGeneration: policy.generation, lastPolicyDeliveredAt: new Date().toISOString() })
const etag = `"policy-rsc-v1-${createHash('sha256').update(script).digest('hex')}"`
reply.header('etag', etag).header('cache-control', 'private, no-cache')
if (ifNoneMatchHits(req.headers['if-none-match'], etag)) return reply.code(304).send()
return reply.type('text/plain').send(script)
})
app.post('/v1/agent/apply-report', async (req) => {
const agentId = req.agentId!
const body = applyReportBodySchema.parse(req.body)
const now = new Date().toISOString()
const prev = repos.getAgent(app.db, agentId)
const reportedDropped = body.packets_dropped ?? 0
const reportedAccepted = body.packets_accepted ?? 0
const prevDropped = prev?.lastApplyPacketsDropped ?? 0
const prevAccepted = prev?.lastApplyPacketsAccepted ?? 0
// Absolute-since-chain-create from agent. If counters reset (re-apply),
// treat the new absolute as the delta; else add the increase.
const deltaDropped =
reportedDropped >= prevDropped
? reportedDropped - prevDropped
: reportedDropped
const deltaAccepted =
reportedAccepted >= prevAccepted
? reportedAccepted - prevAccepted
: reportedAccepted
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted
// Chain/set counters were flushed (policy apply). Zero per-IP baselines so
// the next epoch of element counters accumulates (zeros are omitted from ip_hits).
// Port hits use a separate dynamic set that is not flushed on apply — leave baselines.
if (reportedDropped < prevDropped) {
repos.resetIpBlockStatsBaselines(app.db, agentId)
}
repos.updateAgent(app.db, agentId, {
lastApplyAt: now,
lastApplyStatus: body.status,
lastApplyError: body.error ?? null,
lastApplyPrefixCount: body.prefix_count ?? 0,
lastApplyPacketsDropped: reportedDropped,
lastApplyPacketsAccepted: reportedAccepted,
totalPacketsDropped: totalDropped,
totalPacketsAccepted: totalAccepted,
lastApplyKernelMethod: body.kernel_method ?? null,
lastSeenAt: now,
lastSeenIp: req.ip,
})
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: reportedDropped,
packetsAccepted: reportedAccepted,
prefixCount: body.prefix_count ?? 0,
kernelMethod: body.kernel_method ?? null,
recordedAt: now,
})
if (body.ip_hits?.length) {
const presence = body.source === 'mikrotik'
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now, {
mode: presence ? 'presence' : 'absolute',
})
}
if (body.port_hits?.length && body.source !== 'mikrotik') {
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: sanitized.rules,
listeners: sanitized.listeners,
})
const rawDigest = createHash('sha256')
.update(payloadJson)
.digest('hex')
.slice(0, 16)
repos.upsertHostFirewallSnapshot(
app.db,
agentId,
payloadJson,
now,
rawDigest,
)
}
return { ok: true }
const result = ingestTelemetry(app.db, req.agentId!, body, req.ip)
return { ...result, ...(body.telemetry_version === 2 ? { report_id: body.report_id, sequence: body.sequence, sequence_floor: repos.getTelemetryState(app.db, req.agentId!)!.sequence } : {}) }
})
app.post('/v1/agent/heartbeat', async (req) => {
+54 -35
View File
@@ -6,13 +6,16 @@ import {
patchAgentBodySchema,
cloneFromBodySchema,
agentIdsBodySchema,
explainQuerySchema,
agentLabelFilterSchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
import { buildInstallUrls } from '../services/install-links.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js'
import { mapAgent, mapOverride } from '../services/row-mappers.js'
import { explainAgentPolicy } from '../services/policy/explain.js'
import { applyPagination } from '../services/pagination.js'
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
@@ -21,10 +24,17 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
const { config } = opts
app.get<{ Querystring: { limit?: string; offset?: string } }>(
app.get<{ Params: { id: string }; Querystring: Record<string, unknown> }>('/agents/:id/explain', async (req) => {
if (!repos.getAgent(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return explainAgentPolicy(app.db, req.params.id, explainQuerySchema.parse(req.query))
})
app.get<{ Querystring: { limit?: string; offset?: string; label_ids?: string | string[] } }>(
'/agents',
async (req) => {
const all = repos.listAgents(app.db)
const labels = agentLabelFilterSchema.parse(req.query).label_ids
const matching = labels?.length ? new Set(repos.agentIdsWithLabels(app.db, labels)) : null
const all = repos.listAgents(app.db).filter((agent) => !matching || matching.has(agent.id))
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
const items = all.map((a) => {
const link = linksByAgent.get(a.id)
@@ -137,11 +147,14 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: new Date().toISOString(),
const updated = app.db.transaction((tx) => {
repos.updateAgent(tx, a.id, {
status: 'approved',
approvedAt: new Date().toISOString(),
})
repos.ensureSharedSetAssigned(tx, a.id)
return repos.getAgent(tx, a.id)!
})
repos.ensureSharedSetAssigned(app.db, a.id)
auditMutation(app, config, req, {
action: 'agent.approve',
targetType: 'app_resource',
@@ -240,12 +253,14 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
'/agents/:id/clone-from/:sourceId',
async (req) => {
const body = cloneFromBodySchema.parse(req.body ?? {})
const updated = repos.cloneRulesFrom(
app.db,
req.params.sourceId,
req.params.id,
body.include_overrides ?? false,
)
if (req.params.id === req.params.sourceId) throw new AppError('VALIDATION_ERROR', 'Cannot clone an agent onto itself', 400)
let updated: ReturnType<typeof repos.cloneRulesFrom>
try {
updated = repos.cloneRulesFrom(app.db, req.params.sourceId, req.params.id, body.include_overrides ?? false)
} catch (err) {
if ((err as { code?: string }).code === 'OVERRIDE_LIMIT') throw new AppError('CONFLICT', (err as Error).message, 409)
throw err
}
if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404)
auditMutation(app, config, req, {
action: 'agent.clone_rules',
@@ -264,16 +279,10 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app.get<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => ({
items: repos.listOverrides(app.db, req.params.id).map((o) => ({
id: o.id,
agent_id: o.agentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
created_at: o.createdAt,
})),
}),
async (req) => {
if (!repos.getAgent(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return { items: repos.listOverrides(app.db, req.params.id).map((row) => mapOverride(row)) }
},
)
app.post<{ Params: { id: string } }>(
@@ -282,16 +291,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const body = createOverrideBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const row = repos.insertOverride(app.db, {
const now = new Date().toISOString()
const expiresAt = body.ttl_seconds === undefined ? body.expires_at ? new Date(body.expires_at).toISOString() : null : new Date(Date.parse(now) + body.ttl_seconds * 1000).toISOString()
if (expiresAt && (expiresAt <= now || Date.parse(expiresAt) - Date.parse(now) > 30 * 86_400_000)) throw new AppError('VALIDATION_ERROR', 'Override expiry must be in the next 30 days', 400)
const row = app.db.transaction((tx) => {
let inserted: ReturnType<typeof repos.insertOverride>
try { inserted = repos.insertOverride(tx, {
id: crypto.randomUUID(),
agentId: a.id,
cidr: body.cidr,
action: body.action,
comment: body.comment ?? null,
reason: body.reason ?? body.comment ?? null,
createdByName: req.authUser?.name?.slice(0, 128) || req.authUser?.email?.slice(0, 128) || null,
expiresAt,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
createdAt: now,
}) } catch (err) {
if ((err as { code?: string }).code === 'OVERRIDE_LIMIT') throw new AppError('CONFLICT', (err as Error).message, 409)
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') throw new AppError('CONFLICT', 'An active override already exists for this network', 409)
throw err
}
repos.bumpAgentGeneration(tx, a.id)
return inserted
})
repos.bumpAgentGeneration(app.db, a.id)
auditMutation(app, config, req, {
action: 'override.create',
targetType: 'app_resource',
@@ -302,23 +325,18 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
agent_id: a.id,
cidr: body.cidr,
action: body.action,
reason: row!.reason, expires_at: expiresAt,
},
})
return {
id: row!.id,
agent_id: row!.agentId,
cidr: row!.cidr,
action: row!.action,
comment: row!.comment,
created_at: row!.createdAt,
}
return mapOverride(row!)
},
)
app.delete<{ Params: { id: string; overrideId: string } }>(
'/agents/:id/overrides/:overrideId',
async (req) => {
repos.deleteOverride(app.db, req.params.overrideId)
const deleted = repos.deleteOverride(app.db, req.params.overrideId, req.params.id)
if (!deleted.changes) throw new AppError('NOT_FOUND', 'Override not found for agent', 404)
repos.bumpAgentGeneration(app.db, req.params.id)
auditMutation(app, config, req, {
action: 'override.delete',
@@ -349,6 +367,7 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
try {
repos.setAgentPolicySets(app.db, a.id, body.set_ids)
} catch (err) {
if ((err as { code?: string }).code === 'POLICY_PINNED') throw err
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
+4
View File
@@ -9,7 +9,9 @@ import { rulesRoutes } from './rules.js'
import { statsRoutes } from './stats.js'
import { portAclRoutes } from './port-acl.js'
import { integrationsEvobgpRoutes } from './integrations-evobgp.js'
import { uiSummaryRoutes } from './ui-summary.js'
import { settingsRoutes } from './settings.js'
import { agentLabelsRoutes } from './agent-labels.js'
/** Aggregates domain control-plane route plugins under /api/v1. */
export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
@@ -17,9 +19,11 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
opts,
) => {
const { config } = opts
await app.register(uiSummaryRoutes)
await app.register(dashboardRoutes, { config })
await app.register(installLinksRoutes, { config })
await app.register(agentsRoutes, { config })
await app.register(agentLabelsRoutes, { config })
await app.register(listsRoutes, { config })
await app.register(policySetsRoutes, { config })
await app.register(rulesRoutes, { config })
+3 -4
View File
@@ -32,12 +32,11 @@ export const dashboardRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
}
})
app.get('/install-context', async () => {
const seed =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
app.get('/install-context', async (_request, reply) => {
reply.header('Cache-Control', 'no-store')
return {
suggested_cp_url: config.publicBaseUrl,
enroll_seed: seed,
enroll_seed_configured: !!(repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed),
install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`,
mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`,
sync_interval_sec: Number(
+37
View File
@@ -0,0 +1,37 @@
import type { FastifyPluginAsync } from 'fastify'
import { enrollmentCredentialBodySchema, tokenRotationExchangeBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { AppError } from '../plugins/error-handler.js'
import { enrollWithCredential, exchangeToken, getCredentialStatus, requestTokenRotation, getOperatorCredentialStatus } from '../services/enrollment.js'
import { auditMutation } from '../services/audit.js'
export const enrollmentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (app, { config }) => {
app.post('/v1/agent/enroll', async (req, reply) => {
const body = enrollmentCredentialBodySchema.parse(req.body)
const credential = req.headers['x-evofw-enrollment-credential']
if (typeof credential !== 'string' || !credential) throw new AppError('UNAUTHORIZED', 'Scoped install credential required', 401)
const { created, agent } = enrollWithCredential(app.db, app.sqlite, config.enrollSeed, body, credential, req.ip)
reply.header('Cache-Control', 'no-store')
return reply.code(created ? 201 : 200).send({ client_id: agent.id, id: agent.id, status: agent.status, name: agent.name })
})
app.get('/v1/agent/credentials', async (req, reply) => {
reply.header('Cache-Control', 'no-store')
return getCredentialStatus(app.sqlite, req.agentId!)
})
app.post('/v1/agent/token-rotation', async (req, reply) => {
const auth = req.headers.authorization
if (!auth?.startsWith('Bearer ')) throw new AppError('UNAUTHORIZED', 'Agent token required', 401)
const result = exchangeToken(app.db, app.sqlite, auth.slice(7).trim(), tokenRotationExchangeBodySchema.parse(req.body))
reply.header('Cache-Control', 'no-store')
return result
})
app.get<{ Params: { id: string } }>('/api/v1/agents/:id/token-rotation', { preHandler: app.requireAuth }, async (req, reply) => {
reply.header('Cache-Control', 'no-store')
return getOperatorCredentialStatus(app.db, app.sqlite, req.params.id)
})
app.post<{ Params: { id: string } }>('/api/v1/agents/:id/token-rotation', { preHandler: app.requireAuth }, async (req, reply) => {
const result = requestTokenRotation(app.db, app.sqlite, req.params.id)
auditMutation(app, config, req, { action: 'agent.update', targetType: 'app_resource', targetId: req.params.id, summary: 'Requested agent token rotation', details: { request_id: result.request_id } })
return reply.code(202).send(result)
})
}
+3 -2
View File
@@ -30,8 +30,8 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
app.post('/install-links', async (req, reply) => {
const body = createInstallLinkBodySchema.parse(req.body)
const linkId = randomToken(10)
const slug = randomToken(16)
const linkId = randomToken(32)
const slug = randomToken(32)
if (
repos.getInstallLink(app.db, linkId) ||
repos.getInstallLinkBySlug(app.db, slug)
@@ -67,6 +67,7 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
platform,
agentId,
createdAt: now,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
useCount: 0,
})
})()
+18 -23
View File
@@ -1,12 +1,14 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
import { decryptSecret } from '../services/secret-cipher.js'
import { fetchEvobgpPages } from '../services/evobgp.js'
export const integrationsEvobgpRoutes: FastifyPluginAsync = async (app) => {
/** Proxy EvoBGP communities for UI autocomplete. */
app.get('/integrations/evobgp/communities', async () => {
const apiUrl = repos.getSetting(app.db, 'evobgp_api_url')
const token = repos.getSetting(app.db, 'evobgp_api_token')
const token = decryptSecret(repos.getSetting(app.db, 'evobgp_api_token'))
if (!apiUrl || !token) {
throw new AppError(
'VALIDATION_ERROR',
@@ -15,29 +17,22 @@ export const integrationsEvobgpRoutes: FastifyPluginAsync = async (app) => {
)
}
const base = apiUrl.replace(/\/$/, '')
const res = await fetch(`${base}/v1/communities?limit=200`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(20_000),
let pages: Record<string, unknown>[]
try { pages = await fetchEvobgpPages(base, token, '/v1/communities', 200) } catch (err) {
throw new AppError('UPSTREAM_ERROR', err instanceof Error ? err.message : 'EvoBGP request failed', 502)
}
const all = pages.flatMap((page) => {
if (!Array.isArray(page.items)) throw new AppError('UPSTREAM_ERROR', 'Invalid EvoBGP communities response', 502)
return page.items.map((item: unknown) => {
if (!item || typeof item !== 'object') throw new AppError('UPSTREAM_ERROR', 'Invalid EvoBGP community', 502)
const value = item as Record<string, unknown>
if (typeof value.id !== 'string' || !value.id || typeof value.community !== 'string' || !value.community || (value.title !== undefined && value.title !== null && typeof value.title !== 'string')) {
throw new AppError('UPSTREAM_ERROR', 'Invalid EvoBGP community', 502)
}
return { id: value.id, community: value.community, title: value.title as string | null | undefined }
})
})
if (!res.ok) {
throw new AppError(
'UPSTREAM_ERROR',
`EvoBGP communities HTTP ${res.status}`,
502,
)
}
const data = (await res.json()) as {
items?: {
id?: string
community?: string
title?: string | null
}[]
}
const items = (data.items ?? [])
.filter((x) => x.id && x.community)
const items = all
.map((x) => ({
id: x.id!,
community: x.community!,
+55 -17
View File
@@ -5,29 +5,46 @@ import {
listEntriesBodySchema,
deleteListEntryBodySchema,
isManualListType,
listEntryInputSchema,
listImportQuerySchema,
listDetailPageQuerySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { refreshIpList } from '../services/lists/refresh.js'
import {
addListEntries,
addListEntriesSummary,
deleteListEntry,
deleteListEntrySummary,
mapListDetail,
mapListDetailPage,
} from '../services/lists/entries.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { maskListConfig, sealListConfig } from '../services/secret-cipher.js'
import { applyPagination } from '../services/pagination.js'
const LIST_IMPORT_BODY_LIMIT = 8 * 1024 * 1024
const MAX_IMPORT_ENTRIES = 100_000
function validateImportBudget(values: string[], structuredCount = 0) {
let count = structuredCount
if (count > MAX_IMPORT_ENTRIES) throw new AppError('VALIDATION_ERROR', 'At most 100000 list entries per import', 400)
for (const value of values) for (const _token of value.matchAll(/[^\n,;\s]+/g)) {
if (++count > MAX_IMPORT_ENTRIES) throw new AppError('VALIDATION_ERROR', 'At most 100000 list entries per import', 400)
}
}
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get<{ Querystring: { limit?: string; offset?: string } }>(
app.get<{ Querystring: { limit?: string; offset?: string; view?: string } }>(
'/lists',
async (req) => {
const lists = repos.listIpLists(app.db)
if (req.query.view !== undefined && req.query.view !== 'summary') throw new AppError('VALIDATION_ERROR', 'Unknown list catalog view', 400)
const lists = req.query.view === 'summary' ? repos.listIpListsSummary(app.db) : repos.listIpLists(app.db)
const counts = repos.countEntriesByListIds(
app.db,
lists.map((l) => l.id),
@@ -36,7 +53,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id: l.id,
name: l.name,
type: l.type,
config_json: maskListConfig(l.configJson),
config_json: 'configJson' in l && typeof l.configJson === 'string' ? maskListConfig(l.configJson) : '{}',
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
@@ -49,22 +66,37 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
},
)
app.post('/lists', async (req) => {
app.post('/lists', { bodyLimit: LIST_IMPORT_BODY_LIMIT }, async (req) => {
const body = createIpListBodySchema.parse(req.body)
const type =
body.type === 'domains' ? 'static' : body.type
const inputConfig = { ...(body.config ?? {}) }
validateImportBudget(body.entries ?? [], (Array.isArray(inputConfig.items) ? inputConfig.items.length : 0) + (Array.isArray(inputConfig.domains) ? inputConfig.domains.length : 0))
if (isManualListType(type) && ((inputConfig.items !== undefined && !Array.isArray(inputConfig.items)) || (inputConfig.domains !== undefined && !Array.isArray(inputConfig.domains)))) {
throw new AppError('VALIDATION_ERROR', 'Manual list items/domains must be arrays', 400)
}
const initialItems = isManualListType(type) && Array.isArray(inputConfig.items)
? inputConfig.items.map((item) => listEntryInputSchema.parse(item))
: []
const initialDomains = isManualListType(type) && Array.isArray(inputConfig.domains)
? inputConfig.domains.map((domain) => listEntryInputSchema.parse({ kind: 'hostname', value: domain }))
: []
if (isManualListType(type)) {
delete inputConfig.items
delete inputConfig.domains
}
const id = crypto.randomUUID()
const list = repos.insertIpList(app.db, {
id,
name: body.name,
type,
configJson: sealListConfig({ ...(body.config ?? {}) }),
configJson: sealListConfig(inputConfig),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.entries?.length && isManualListType(type)) {
if (isManualListType(type) && (body.entries?.length || initialItems.length || initialDomains.length)) {
try {
await addListEntries(app.db, id, { values: body.entries })
await addListEntries(app.db, id, { values: body.entries, items: [...initialItems, ...initialDomains] })
} catch (err) {
repos.deleteIpList(app.db, id)
throw new AppError(
@@ -94,22 +126,25 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
const detail = mapListDetail(app.db, req.params.id)
const detail = Object.keys(req.query as object).length > 0
? mapListDetailPage(app.db, req.params.id, listDetailPageQuerySchema.parse(req.query))
: mapListDetail(app.db, req.params.id)
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
return detail
})
app.post<{ Params: { id: string } }>(
'/lists/:id/entries',
{ bodyLimit: LIST_IMPORT_BODY_LIMIT },
async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
const body = listEntriesBodySchema.parse(req.body)
const summary = listImportQuerySchema.parse(req.query).return === 'summary'
validateImportBudget(body.values ?? [], body.items?.length ?? 0)
try {
const result = await addListEntries(app.db, l.id, {
values: body.values,
items: body.items,
})
const input = { values: body.values, items: body.items }
const result = summary ? await addListEntriesSummary(app.db, l.id, input) : await addListEntries(app.db, l.id, input)
auditMutation(app, config, req, {
action: 'list.entries.add',
targetType: 'app_resource',
@@ -117,10 +152,10 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
summary: `Добавлены записи в список: ${l.name}`,
details: {
list_id: l.id,
entry_count: result.entries.length,
entry_count: 'count' in result ? result.count : result.entries.length,
},
})
return mapListDetail(app.db, l.id) ?? result
return summary ? result : mapListDetail(app.db, l.id) ?? result
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
@@ -137,8 +172,9 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
const body = deleteListEntryBodySchema.parse(req.body)
const summary = listImportQuerySchema.parse(req.query).return === 'summary'
try {
await deleteListEntry(app.db, l.id, body.value)
const result = summary ? await deleteListEntrySummary(app.db, l.id, body.value) : await deleteListEntry(app.db, l.id, body.value)
auditMutation(app, config, req, {
action: 'list.entries.delete',
severity: 'warning',
@@ -147,7 +183,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
summary: `Удалена запись из списка: ${l.name}`,
details: { list_id: l.id, value: body.value },
})
return mapListDetail(app.db, l.id)
return summary ? result : mapListDetail(app.db, l.id)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
@@ -175,7 +211,9 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
repos.deleteIpList(app.db, req.params.id)
try { repos.deleteIpList(app.db, req.params.id) } catch (err) {
throw new AppError('CONFLICT', err instanceof Error ? err.message : 'List is in use', 409)
}
if (l) {
auditMutation(app, config, req, {
action: 'list.delete',
+68
View File
@@ -0,0 +1,68 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { createPolicySnapshotSchema, policySnapshotDiffSchema, policyPageSchema, createPolicyRolloutSchema, policyRolloutTransitionSchema, releasePolicyPinSchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { AppError } from '../plugins/error-handler.js'
import { auditMutation } from '../services/audit.js'
import { createPolicySnapshot, diffPolicySnapshot, startPolicyRollout, getPolicyRolloutView, confirmPolicyRollout, rollbackPolicyRollout, releaseAgentPolicyPin } from '../services/policy/rollouts.js'
export const policyRolloutsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (app, { config }) => {
const audit = (req: Parameters<typeof auditMutation>[2], action: string, id: string) => auditMutation(app, config, req, {
action, targetType: 'app_resource', targetId: id, summary: action, details: { resource_id: id },
})
app.post('/policies/snapshots', async (req, reply) => {
const body = createPolicySnapshotSchema.parse(req.body)
const { payload_json: _, ...snapshot } = createPolicySnapshot(app.db, body.source_agent_id, body.name)
audit(req, 'policy_snapshot.create', snapshot.id)
return reply.code(201).send(snapshot)
})
app.get('/policies/snapshots', async (req) => {
const page = policyPageSchema.parse(req.query)
return { items: repos.listPolicySnapshots(app.db, page.limit, page.offset) }
})
app.get<{ Params: { id: string } }>('/policies/snapshots/:id', async (req) => {
const snapshot = repos.getPolicySnapshot(app.db, req.params.id)
if (!snapshot) throw new AppError('NOT_FOUND', 'Snapshot not found', 404)
const { payload_json, ...metadata } = snapshot
return { ...metadata, payload: JSON.parse(payload_json) }
})
app.delete<{ Params: { id: string } }>('/policies/snapshots/:id', async (req) => {
if (!repos.getPolicySnapshot(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Snapshot not found', 404)
if (!repos.deletePolicySnapshot(app.db, req.params.id)) throw new AppError('SNAPSHOT_IN_USE', 'Referenced snapshots cannot be deleted', 409)
audit(req, 'policy_snapshot.delete', req.params.id)
return { ok: true }
})
app.post<{ Params: { id: string } }>('/policies/snapshots/:id/diff', async (req) => diffPolicySnapshot(app.db, req.params.id, policySnapshotDiffSchema.parse(req.body).agent_ids))
app.post('/policies/rollouts', async (req, reply) => {
const rollout = startPolicyRollout(app.db, createPolicyRolloutSchema.parse(req.body))
audit(req, 'policy_rollout.start', rollout.id)
return reply.code(201).send(rollout)
})
app.get('/policies/rollouts', async (req) => {
const page = policyPageSchema.parse(req.query)
return { items: repos.listPolicyRollouts(app.db, page.limit, page.offset) }
})
app.get<{ Params: { id: string } }>('/policies/rollouts/:id', async (req) => getPolicyRolloutView(app.db, req.params.id))
app.delete<{ Params: { id: string } }>('/policies/rollouts/:id', async (req) => {
if (!repos.getPolicyRollout(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Rollout not found', 404)
if (!repos.deletePolicyRollout(app.db, req.params.id)) throw new AppError('ROLLOUT_IN_USE', 'Finish rollout and release every pin before deleting history', 409)
audit(req, 'policy_rollout.delete', req.params.id)
return { ok: true }
})
app.post<{ Params: { id: string } }>('/policies/rollouts/:id/confirm', async (req) => {
const result = confirmPolicyRollout(app.db, req.params.id, policyRolloutTransitionSchema.parse(req.body).expected_version)
audit(req, 'policy_rollout.confirm', result.id)
return result
})
app.post<{ Params: { id: string } }>('/policies/rollouts/:id/rollback', async (req) => {
const result = rollbackPolicyRollout(app.db, req.params.id, policyRolloutTransitionSchema.parse(req.body).expected_version)
audit(req, 'policy_rollout.rollback', result.id)
return result
})
app.post<{ Params: { id: string } }>('/agents/:id/policy-pin/release', async (req) => {
const body = releasePolicyPinSchema.parse(req.body)
const result = releaseAgentPolicyPin(app.db, req.params.id, body.rollout_id, body.expected_generation)
audit(req, 'policy_pin.release', req.params.id)
return result
})
}
+1 -1
View File
@@ -59,7 +59,7 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
description: body.description,
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
})
if (body.enabled !== undefined) {
if (body.enabled !== undefined || updated!.name !== s.name) {
repos.bumpAgentsForSet(app.db, s.id)
}
auditMutation(app, config, req, {
+10 -4
View File
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { encryptSecret } from '../services/secret-cipher.js'
import { auditMutation } from '../services/audit.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -10,30 +11,35 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
const { config } = opts
app.get('/settings', async () => {
app.get('/settings', async (_req, reply) => {
reply.header('Cache-Control', 'no-store')
const rows = repos.listSettings(app.db)
const map: Record<string, string> = {}
for (const r of rows) {
if (r.key === 'evobgp_api_token' && r.value) {
if ((r.key === 'evobgp_api_token' || r.key === 'enroll_seed') && r.value) {
map[r.key] = '********'
} else {
map[r.key] = r.value
}
}
if (!map.enroll_seed) map.enroll_seed = config.enrollSeed
if (!map.enroll_seed && config.enrollSeed) map.enroll_seed = '********'
return map
})
app.put('/settings', async (req) => {
const body = putSettingsBodySchema.parse(req.body)
const changedKeys: string[] = []
for (const [k, v] of Object.entries(body)) {
if (k === 'evobgp_api_token' && v === '********') continue
if ((k === 'evobgp_api_token' || k === 'enroll_seed') && v === '********') continue
if (repos.getSetting(app.db, k) === v && k !== 'evobgp_api_token') continue
repos.setSetting(
app.db,
k,
k === 'evobgp_api_token' ? encryptSecret(v) : v,
)
changedKeys.push(k)
}
if (changedKeys.length) auditMutation(app, config, req, { action: 'settings.update', targetType: 'app_resource', targetId: 'settings', summary: 'Обновлены настройки', details: { changed_keys: changedKeys } })
return { ok: true }
})
}
+44 -9
View File
@@ -4,6 +4,7 @@ import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js'
import { queryTimeseries, type TimeseriesQuery } from '../services/analytics.js'
export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -11,6 +12,47 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
const { config } = opts
app.get<{ Querystring: TimeseriesQuery }>('/stats/timeseries', async (req) => queryTimeseries(app.db, req.query))
app.get<{ Querystring: TimeseriesQuery }>('/stats/timeseries.csv', async (req, reply) => {
const result = queryTimeseries(app.db, req.query)
const rows = ['timestamp,packets_dropped,packets_accepted,pps,coverage,estimated,partial,truncated,lost_reports',
...result.items.map((point) => [point.timestamp, point.packets_dropped ?? '', point.packets_accepted ?? '', point.pps ?? '', point.coverage, point.estimated, point.quality.partial, point.quality.truncated, point.quality.lost_reports].join(','))]
return reply.header('Content-Disposition', 'attachment; filename="evofw-timeseries.csv"').type('text/csv; charset=utf-8').send(rows.join('\r\n') + '\r\n')
})
app.get<{ Params: { id: string }; Querystring: { from?: string; to?: string; cursor?: string; limit?: string } }>('/agents/:id/stats/raw', async (req) => {
if (!repos.getAgent(app.db, req.params.id)) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const from = req.query.from ?? new Date(Date.now() - 86_400_000).toISOString()
const to = req.query.to ?? new Date().toISOString()
const limit = req.query.limit === undefined ? 100 : Number(req.query.limit)
if (!Number.isFinite(Date.parse(from)) || !Number.isFinite(Date.parse(to)) || Date.parse(from) >= Date.parse(to) || !Number.isInteger(limit) || limit < 1 || limit > 500) throw new AppError('VALIDATION_ERROR', 'Invalid raw sample range or limit (1..500)', 400)
let cursor: { at: string; id: string } | undefined
if (req.query.cursor) {
try {
if (req.query.cursor.length > 512 || !/^[A-Za-z0-9_-]+$/.test(req.query.cursor)) throw new Error('Invalid cursor')
const decoded = JSON.parse(Buffer.from(req.query.cursor, 'base64url').toString('utf8')) as { at?: unknown; id?: unknown }
if (typeof decoded.at !== 'string' || !Number.isFinite(Date.parse(decoded.at)) || typeof decoded.id !== 'string' || decoded.id.length > 128) throw new Error('Invalid cursor')
cursor = { at: decoded.at, id: decoded.id }
} catch { throw new AppError('VALIDATION_ERROR', 'Invalid sample cursor', 400) }
}
const found = repos.listRawStatsPage(app.db, req.params.id, new Date(from).toISOString(), new Date(to).toISOString(), limit, cursor)
const hasMore = found.length > limit, selected = found.slice(0, limit), last = selected.at(-1)
return {
items: selected.map((sample) => ({
id: sample.id, agent_id: sample.agentId, recorded_at: sample.recordedAt, observed_at: sample.observedAt,
telemetry_version: sample.telemetryVersion, report_id: sample.reportId, sequence: sample.sequence,
packets_dropped: sample.droppedPresent === 0 ? null : sample.packetsDropped,
packets_accepted: sample.acceptedPresent === 0 ? null : sample.packetsAccepted,
delta_dropped: sample.deltaDropped, delta_accepted: sample.deltaAccepted,
dropped_interval_start: sample.droppedIntervalStart, accepted_interval_start: sample.acceptedIntervalStart,
interval_ms: sample.intervalMs, quality: sample.qualityJson ? JSON.parse(sample.qualityJson) : null,
detail_counts: sample.detailCountsJson ? JSON.parse(sample.detailCountsJson) : null,
})),
has_more: hasMore,
next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ at: last.recordedAt, id: last.id })).toString('base64url') : null,
}
})
app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({
items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({
id: s.id,
@@ -72,15 +114,8 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, agent.id, {
lastApplyPacketsDropped: 0,
lastApplyPacketsAccepted: 0,
totalPacketsDropped: 0,
totalPacketsAccepted: 0,
})
repos.deleteStatsSamplesForAgent(app.db, agent.id)
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
repos.deletePortBlockStatsForAgent(app.db, agent.id)
repos.resetDisplayedStats(app.db, agent.id)
const updated = repos.getAgent(app.db, agent.id)
auditMutation(app, config, req, {
action: 'agent.stats_reset',
severity: 'info',
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyPluginAsync } from 'fastify'
import { hasPermission, type UiSummary } from '@evofw/shared'
/** Authentication comes from the protected API scope; each section is independent. */
export const uiSummaryRoutes: FastifyPluginAsync = async (app) => {
app.get('/ui/summary', async (req) => {
const now = new Date()
const summary: UiSummary = { updated_at: now.toISOString() }
const permissions = req.authUser?.permissions ?? []
const allowed = (section: string) => req.authUser?.isAdmin || hasPermission(permissions, `fw:${section}:read`)
const agentsAllowed = allowed('agents'), dashboardAllowed = allowed('dashboard')
let counts: { total: number; approved: number; online: number; pending: number; dropped: number; accepted: number } | undefined
if (agentsAllowed || dashboardAllowed) {
counts = app.sqlite.prepare(`SELECT count(*) AS total,
coalesce(sum(status='approved'),0) AS approved,
coalesce(sum(status='approved' AND last_seen_at >= ?),0) AS online,
coalesce(sum(status='pending'),0) AS pending,
coalesce(sum(total_packets_dropped),0) AS dropped,
coalesce(sum(total_packets_accepted),0) AS accepted FROM agents`).get(new Date(now.getTime() - 5 * 60_000).toISOString()) as typeof counts
}
let lists: { total: number; failed: number } | undefined
if (allowed('lists') || dashboardAllowed) lists = app.sqlite.prepare("SELECT count(*) AS total, coalesce(sum(last_error IS NOT NULL AND last_error != ''),0) AS failed FROM ip_lists").get() as typeof lists
if (agentsAllowed && counts) summary.agents = { approved: counts.approved, online: counts.online, pending: counts.pending, offline: counts.approved - counts.online }
if (allowed('lists') && lists) summary.lists = lists
if (dashboardAllowed && counts && lists) summary.dashboard = {
agents_total: counts.total, agents_approved: counts.approved, agents_online: counts.online, agents_pending: counts.pending,
packets_dropped: counts.dropped, packets_accepted: counts.accepted, lists_total: lists.total,
}
if (allowed('policies')) summary.policies = app.sqlite.prepare('SELECT (SELECT count(*) FROM policy_sets) AS sets_total, (SELECT count(*) FROM policy_rules) AS rules_total').get() as UiSummary['policies']
return summary
})
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyPluginAsync } from 'fastify'
import { webhookBodySchema, webhookPatchSchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { Webhooks } from '../services/webhooks.js'
import { auditMutation, recordAudit } from '../services/audit.js'
export const webhooksRoutes: FastifyPluginAsync<{ config: AppConfig; memory?: boolean }> = async (app, { config, memory }) => {
const worker = new Webhooks(app.sqlite, config, undefined, () => {
app.log.warn('Webhook delivery capacity exhausted; notifications dropped')
recordAudit(app, config, { action: 'settings.update', targetType: 'app_resource', targetId: 'webhooks', summary: 'Webhook delivery capacity exhausted', details: { changed_keys: ['webhook_dropped_notifications'] } })
})
const audit = (request: Parameters<typeof auditMutation>[2], id: string, operation: string) => auditMutation(app, config, request, { action: 'settings.update', targetType: 'app_resource', targetId: id, summary: `Webhook ${operation}`, details: { changed_keys: ['webhooks'] } })
app.get('/webhooks', async () => worker.list())
app.post('/webhooks', async (request, reply) => {
const result = worker.create(webhookBodySchema.parse(request.body)); audit(request, result.id, 'created')
return reply.code(201).send(result)
})
app.patch<{ Params: { id: string } }>('/webhooks/:id', async request => {
const result = worker.update(request.params.id, webhookPatchSchema.parse(request.body)); audit(request, result.id, 'updated'); return result
})
app.delete<{ Params: { id: string } }>('/webhooks/:id', async request => {
const result = worker.remove(request.params.id); audit(request, request.params.id, 'deleted'); return result
})
app.get<{ Params: { id: string } }>('/webhooks/:id/deliveries', async request => worker.deliveries(request.params.id))
const timer = memory ? null : setInterval(() => { void worker.tick().catch(() => app.log.warn('Webhook worker failed')) }, 30_000)
timer?.unref()
app.addHook('onClose', async () => { if (timer) clearInterval(timer); await worker.close() })
}
@@ -0,0 +1,64 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const python = process.platform === 'win32' ? 'python' : 'python3'
const dirs: string[] = []
function embedded(name: string) { const s = source.slice(source.indexOf(`${name}() {`)); const start = s.indexOf("<<'PY'") + 7; return s.slice(start, s.indexOf('\nPY', start)).trimStart() }
function parse(patch: object) { const dir = mkdtempSync(join(tmpdir(), 'evofw-budget-')); dirs.push(dir); const file = join(dir, 'candidate.json'); writeFileSync(file, JSON.stringify({ hash: 'sha256:' + 'a'.repeat(64), generation: 1, default_action: 'accept', deny_cidrs: [], allow_cidrs: [], port_rules: [], ...patch })); const result = spawnSync(python, ['-c', embedded('parse_policy'), file, dir], { encoding: 'utf8' }); return { dir, result } }
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
function addresses(n: number) { return Array.from({ length: n }, (_, i) => { const v = i * 2; return `10.${(v >>> 16) & 255}.${(v >>> 8) & 255}.${v & 255}/32` }) }
describe('agent effective policy budgets', () => {
it('accepts exactly 50k canonical unique memberships and rejects 50k+1 before emitting apply data', () => {
const accepted = parse({ deny_cidrs: addresses(50000) }); expect(accepted.result.status, accepted.result.stderr).toBe(0)
const rejected = parse({ deny_cidrs: addresses(50001) }); expect(rejected.result.status).not.toBe(0)
expect(readFileSync(join(rejected.dir, 'apply-error-code'), 'utf8')).toBe('POLICY_BUDGET_EXCEEDED')
expect(() => readFileSync(join(rejected.dir, 'normalized.json'))).toThrow()
}, 15000)
it('counts adjacent and overlapping unique CIDRs before optional kernel collapse', () => {
const adjacent = Array.from({ length: 50000 }, (_, i) => `10.${(i >>> 16) & 255}.${(i >>> 8) & 255}.${i & 255}/32`)
const accepted = parse({ deny_cidrs: adjacent }); expect(accepted.result.status, accepted.result.stderr).toBe(0)
expect(JSON.parse(readFileSync(join(accepted.dir, 'normalized.json'), 'utf8')).deny_cidrs.length).toBeLessThan(50000)
const rejected = parse({ deny_cidrs: [...adjacent, '10.0.0.0/8'] })
expect(rejected.result.status).not.toBe(0)
expect(readFileSync(join(rejected.dir, 'apply-error-code'), 'utf8')).toBe('POLICY_BUDGET_EXCEEDED')
}, 15000)
it('counts repeated CIDRs separately across distinct expanded Port ACL source sets', () => {
const src = addresses(1000)
const { dir, result } = parse({ port_rules: Array.from({ length: 51 }, (_, i) => ({ id: `rule-${i}`, action: 'open', protocol: 'tcp', port_start: 443, src_cidrs: src })) })
expect(result.status).not.toBe(0)
expect(readFileSync(join(dir, 'apply-error-code'), 'utf8')).toBe('POLICY_BUDGET_EXCEEDED')
})
it('caps raw arrays before expensive normalization and accepts large compressible raw input within the cap', () => {
const rejected = parse({ deny_cidrs: Array(200001).fill('192.0.2.1/32') })
expect(rejected.result.status).not.toBe(0)
expect(readFileSync(join(rejected.dir, 'apply-error-code'), 'utf8')).toBe('POLICY_BUDGET_EXCEEDED')
const accepted = parse({ deny_cidrs: Array(100000).fill('192.0.2.1/32') })
expect(accepted.result.status, accepted.result.stderr).toBe(0)
expect(JSON.parse(readFileSync(join(accepted.dir, 'normalized.json'), 'utf8')).deny_cidrs).toEqual(['192.0.2.1/32'])
}, 15000)
it('caps serialized Port ACL rule count', () => {
const { dir, result } = parse({ port_rules: Array.from({ length: 1001 }, (_, i) => ({ id: `rule-${i}`, action: 'close', protocol: 'tcp', port_start: 22, src_cidrs: [] })) })
expect(result.status).not.toBe(0)
expect(readFileSync(join(dir, 'apply-error-code'), 'utf8')).toBe('POLICY_BUDGET_EXCEEDED')
})
it('rejects an oversized JSON document before parsing it', () => {
const { dir } = parse({})
writeFileSync(join(dir, 'oversized.json'), ' '.repeat(8388609))
const result = spawnSync(python, ['-c', embedded('parse_policy'), join(dir, 'oversized.json'), dir], { encoding: 'utf8' })
expect(result.status).not.toBe(0)
expect(readFileSync(join(dir, 'apply-error-code'), 'utf8')).toBe('POLICY_BUDGET_EXCEEDED')
})
it('accepts protocol=both expansion through tuple uniqueness and emits distinct stable traffic identifiers', () => {
const { dir, result } = parse({ port_rules: ['tcp', 'udp'].map((protocol) => ({ id: 'shared-id', action: 'open', protocol, port_start: 443, src_cidrs: ['0.0.0.0/0', '::/0'] })) })
expect(result.status, result.stderr).toBe(0)
const generated = spawnSync(python, ['-c', embedded('render_nft_batch'), join(dir, 'normalized.json'), '1', '1', '0'], { encoding: 'utf8' })
expect(generated.status, generated.stderr).toBe(0)
const comments = [...generated.stdout.matchAll(/input .*comment "(evofw-port-[a-f0-9]+-(?:tcp|udp)-v[46])"/g)].map((match) => match[1])
expect(comments).toHaveLength(4)
expect(new Set(comments).size).toBe(4)
})
})
@@ -0,0 +1,63 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const python = process.platform === 'win32' ? 'python' : 'python3'
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const pythonPath = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
const dirs: string[] = []
function workspace() { const d = mkdtempSync(join(tmpdir(), 'evofw-cache-')); dirs.push(d); return d }
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
function embedded(name: string) { const s = source.slice(source.indexOf(`${name}() {`)); const a = s.indexOf("<<'PY'") + 7; return s.slice(a, s.indexOf('\nPY', a)).trimStart() }
function worker(d: string, op: string, ...args: string[]) { return spawnSync(python, ['-c', embedded('telemetry_worker'), d, d, op, ...args], { encoding: 'utf8', env: { ...process.env, EVOFW_TEST_BOOT_ID: 'cache-boot' } }) }
const policy = (letter = 'a', generation = 1) => ({ hash: 'sha256:' + letter.repeat(64), generation, default_action: 'accept', deny_cidrs: [], allow_cidrs: [], port_rules: [] })
function cache(d: string) { const body = JSON.stringify(policy()); writeFileSync(join(d, 'candidate.json'), body); writeFileSync(join(d, 'normalized.json'), body); writeFileSync(join(d, 'policy-http-status'), '200'); writeFileSync(join(d, 'policy-headers.txt'), 'HTTP/1.1 200 OK\r\nETag: "etag-first"\r\n'); expect(worker(d, 'init').status).toBe(0); expect(worker(d, 'commit-policy').status).toBe(0); writeFileSync(join(d, 'last_hash'), policy().hash); writeFileSync(join(d, 'kernel_state'), 'nft old-digest\n') }
const workerFunctions = source.slice(source.indexOf('telemetry_worker() {'), source.indexOf('telemetry_worker init'))
const requestFunction = source.slice(source.indexOf('curl_policy() {'), source.indexOf('# Do not use `if ! cmd;'))
const parseFunction = source.slice(source.indexOf('parse_policy() {'), source.indexOf('HASH="";'))
function shell(d: string, content: string) { writeFileSync(join(d, 'test.sh'), `set -euo pipefail\nSTATE_DIR=.; RUN_DIR=.; HASH_FILE=last_hash; POLICY_FILE=last_policy.json; EVOFW_CP_URL=https://unused.invalid; CLIENT_TOKEN=fixture; BACKEND=nft; DENY=(); ALLOW=(); APPLY_ERROR_CODE=apply_failed;\npython3() { '${pythonPath}' "$@"; }\nlog() { :; }\n${workerFunctions}\n${requestFunction}\n${parseFunction}\n${content}`); return spawnSync(bash, ['--noprofile', '--norc', join(d, 'test.sh').replaceAll('\\', '/')], { cwd: d, encoding: 'utf8', timeout: 15000 }) }
function curl(status: number) { return `curl() { printf '%s\\n' "$*" > curl-args; local dest= headers=; while (($#)); do case "$1" in -o) dest=$2; shift 2;; -D) headers=$2; shift 2;; *) shift;; esac; done; printf 'HTTP/1.1 ${status} result\\r\\nETag: "etag-next"\\r\\n' > "$headers"; ${status === 304 ? ': > "$dest"' : 'cp response.json "$dest"'}; printf '${status}'; }\n` }
describe('agent last-good policy validator cache and typed upstream rejections', () => {
it('makes a cold-cache request without a validator', () => {
const d = workspace(); expect(worker(d, 'init').status).toBe(0); writeFileSync(join(d, 'response.json'), JSON.stringify(policy()))
const r = shell(d, curl(200) + 'curl_policy candidate.json\nparse_policy candidate.json\n')
expect(r.status, r.stderr).toBe(0); expect(readFileSync(join(d, 'curl-args'), 'utf8')).not.toContain('If-None-Match')
expect(() => readFileSync(join(d, 'last_policy.json'))).toThrow()
})
it('reuses a matching durable body on304 and still repairs drift instead of skipping sync', () => {
const d = workspace(); cache(d)
const main = source.slice(source.indexOf('KERNEL_STATE_FILE="$STATE_DIR/kernel_state"'))
const r = shell(d, curl(304) + `type() { return 0; }\nkernel_digest() { echo repaired; }\ncollect_telemetry() { :; }\nflush_reports() { :; }\nsend_report() { :; }\napply_nft() { echo apply >> applied; KERNEL_METHOD=nft; APPLIED=0; APPLIED_FAMILIES=4,6; }\ncurl_policy candidate.json\nparse_policy candidate.json\n${main}`)
expect(r.status, r.stderr).toBe(0)
expect(readFileSync(join(d, 'curl-args'), 'utf8')).toContain('If-None-Match: "etag-first"')
expect(readFileSync(join(d, 'applied'), 'utf8').trim()).toBe('apply')
const saved = JSON.parse(readFileSync(join(d, 'last_policy.json'), 'utf8'))
expect(saved.hash).toBe(policy().hash); expect(saved._response_etag).toBe('"etag-first"'); expect(JSON.parse(saved._response_body)).toEqual(policy())
})
it('preserves the durable matching body and validator when a new candidate fails kernel apply', () => {
const d = workspace(); cache(d); const before = readFileSync(join(d, 'last_policy.json'), 'utf8')
writeFileSync(join(d, 'response.json'), JSON.stringify(policy('b', 2)))
const main = source.slice(source.indexOf('KERNEL_STATE_FILE="$STATE_DIR/kernel_state"'))
const r = shell(d, curl(200) + `type() { return 0; }\nkernel_digest() { echo repaired; }\ncollect_telemetry() { :; }\nflush_reports() { :; }\napply_nft() { return 1; }\ncurl_policy candidate.json\nparse_policy candidate.json\n${main}`)
expect(r.status).not.toBe(0); expect(readFileSync(join(d, 'last_policy.json'), 'utf8')).toBe(before)
})
it('does not accept304 without a durable validated body', () => {
const d = workspace(); expect(worker(d, 'init').status).toBe(0)
const r = shell(d, curl(304) + 'if curl_policy candidate.json; then exit 9; fi\n')
expect(r.status, r.stderr).toBe(0); expect(() => readFileSync(join(d, 'last_policy.json'))).toThrow()
})
it.each([
[422, 'POLICY_BUDGET_EXCEEDED', 1, 'POLICY_BUDGET_EXCEEDED'],
[422, 'SOME_OTHER_ERROR', 1, 'apply_failed'],
[403, 'POLICY_BUDGET_EXCEEDED', 2, 'apply_failed'],
])('handles HTTP%s error %s without false policy acknowledgement', (status, code, expectedRc, expectedCode) => {
const d = workspace(); cache(d); const before = readFileSync(join(d, 'last_policy.json'), 'utf8')
writeFileSync(join(d, 'response.json'), JSON.stringify({ error: { code } }))
const r = shell(d, curl(status as number) + 'rc=0; curl_policy candidate.json || rc=$?; printf "%s %s" "$rc" "$APPLY_ERROR_CODE" > result\n')
expect(r.status, r.stderr).toBe(0); expect(readFileSync(join(d, 'result'), 'utf8')).toBe(`${expectedRc} ${expectedCode}`)
expect(readFileSync(join(d, 'last_policy.json'), 'utf8')).toBe(before)
})
})
@@ -0,0 +1,148 @@
import { readFileSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const linux = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const install = readFileSync(new URL('../agent-scripts/install.sh', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const router = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const worker = linux.split("<<'PY_CREDENTIALS'\n")[1]!.split('\nPY_CREDENTIALS')[0]!
const fakeTransport = String.raw`
import json, os, sys, urllib.request, urllib.error
from pathlib import Path
server_path=Path(sys.argv[1])/'server.json'
# Windows test harness only: real Linux directory fsync is retained in production.
real_open=os.open
def test_open(path, flags, *args):
if os.path.isdir(path): return real_open(str(Path(sys.argv[1])/'directory-fsync-fixture'), os.O_RDWR|os.O_CREAT, 0o600)
return real_open(path,flags,*args)
os.open=test_open
class Response:
def __init__(self,value): self.value=value
def __enter__(self): return self
def __exit__(self,*args): pass
def read(self,*args): return json.dumps(self.value).encode()
def fetch(req, timeout):
server=json.loads(server_path.read_text())
auth=req.headers['Authorization'][7:]
if req.full_url.endswith('/credentials'):
if auth!=server['current']: raise urllib.error.HTTPError(req.full_url,401,'invalid',{},None)
return Response({'client_id':'agent-test','rotation':None if server.get('next') or server.get('expired') else {'request_id':server['request_id']},'completed_rotation':{'request_id':server['request_id']} if server.get('next') else None})
if server.get('expired') or server.get('reject_exchange'): raise urllib.error.HTTPError(req.full_url,401,'expired',{},None)
body=json.loads(req.data)
if body['request_id']!=server['request_id']: raise urllib.error.URLError('request mismatch')
if server.get('next') and body['next_token']!=server['next']: raise urllib.error.URLError('replay mismatch')
if auth not in [server['current'],server.get('previous')]: raise urllib.error.HTTPError(req.full_url,401,'invalid',{},None)
server['previous']=auth; server['current']=body['next_token']; server['next']=body['next_token']
lose=server.pop('lose_ack',False)
server_path.write_text(json.dumps(server))
if lose: raise urllib.error.URLError('ACK lost after committed exchange')
return Response({'ok':True,'request_id':body['request_id'],'client_id':'wrong' if server.get('bad_ack') else 'agent-test'})
urllib.request.urlopen=fetch
`
function fixture(extra: object = {}) {
const dir = mkdtempSync(join(tmpdir(), 'evofw-credential-'))
const old = 'evofw_' + 'a'.repeat(43)
const conf = join(dir, 'agent.conf')
writeFileSync(conf, `CLIENT_TOKEN='${old}'\nCLIENT_ID='agent-test'\n`)
writeFileSync(join(dir, 'server.json'), JSON.stringify({ current: old, request_id: '12345678-1234-4234-8234-123456789abc', ...extra }))
const run = () => spawnSync('python', ['-c', fakeTransport + '\n' + worker, dir, conf, 'https://fw.example.com', old, 'agent-test'], { encoding: 'utf8' })
return { dir, conf, old, run }
}
describe('credential client restart and ACK recovery (mock transport; no native firewall)', () => {
it('retains exactly one new token after lost ACK and resumes it on restart before committing active config', () => {
const f = fixture({ lose_ack: true })
try {
const first = f.run()
expect(first.status, first.stderr).toBe(1)
const pending = JSON.parse(readFileSync(join(f.dir, 'token-rotation-pending.json'), 'utf8').replace(/\r\n/g, '\n'))
expect(readFileSync(f.conf, 'utf8').replace(/\r\n/g, '\n')).toContain(f.old)
const next = pending.next_token
const retry = f.run()
expect(retry.status, retry.stderr).toBe(0)
expect(retry.stdout.trim()).toBe(next)
expect(readFileSync(f.conf, 'utf8').replace(/\r\n/g, '\n')).toContain(next)
expect(readFileSync(f.conf, 'utf8').replace(/\r\n/g, '\n')).not.toContain(f.old)
expect(existsSync(join(f.dir, 'token-rotation-pending.json'))).toBe(false)
expect(first.stderr + retry.stderr).not.toContain(next)
} finally { rmSync(f.dir, { recursive: true, force: true }) }
})
it('recovers a committed candidate after old-token replay expires during offline restart', () => {
const f = fixture({ lose_ack: true })
try {
expect(f.run().status).toBe(1)
const pending = JSON.parse(readFileSync(join(f.dir, 'token-rotation-pending.json'), 'utf8'))
const server = JSON.parse(readFileSync(join(f.dir, 'server.json'), 'utf8'))
writeFileSync(join(f.dir, 'server.json'), JSON.stringify({ ...server, expired: true }))
const resumed = f.run()
expect(resumed.status, resumed.stderr).toBe(0)
expect(readFileSync(f.conf, 'utf8')).toContain(pending.next_token)
expect(existsSync(join(f.dir, 'token-rotation-pending.json'))).toBe(false)
} finally { rmSync(f.dir, { recursive: true, force: true }) }
})
it('abandons an uncommitted expired exchange only after old-token proof, and reconciles a later request', () => {
const f = fixture({ expired: true })
try {
const requestId = '12345678-1234-4234-8234-123456789abc'
writeFileSync(join(f.dir, 'token-rotation-pending.json'), JSON.stringify({ request_id: requestId, old_token: f.old, next_token: 'evofw_' + 'b'.repeat(43), origin: 'https://fw.example.com', client_id: 'agent-test' }))
const resumed = f.run()
expect(resumed.status, resumed.stderr).toBe(0) // Caller continues policy and telemetry with this credential.
expect(resumed.stdout.trim()).toBe(f.old)
expect(readFileSync(f.conf, 'utf8')).toContain(f.old)
expect(existsSync(join(f.dir, 'token-rotation-pending.json'))).toBe(false)
const server = JSON.parse(readFileSync(join(f.dir, 'server.json'), 'utf8'))
expect(server.current).toBe(f.old)
writeFileSync(join(f.dir, 'server.json'), JSON.stringify({ ...server, expired: false, request_id: '22345678-1234-4234-8234-123456789abc' }))
const newer = f.run()
expect(newer.status, newer.stderr).toBe(0)
expect(newer.stdout.trim()).not.toBe(f.old)
} finally { rmSync(f.dir, { recursive: true, force: true }) }
})
it('retains pending state when the same request is still active after an exchange refusal', () => {
const f = fixture({ reject_exchange: true })
try {
expect(f.run().status).toBe(1)
const bytes = readFileSync(join(f.dir, 'token-rotation-pending.json'), 'utf8')
expect(f.run().status).toBe(1)
expect(readFileSync(join(f.dir, 'token-rotation-pending.json'), 'utf8')).toBe(bytes)
expect(readFileSync(f.conf, 'utf8')).toContain(f.old)
} finally { rmSync(f.dir, { recursive: true, force: true }) }
})
it('keeps the active token and pending exchange unchanged after a mismatched ACK', () => {
const f = fixture({ bad_ack: true })
try {
expect(f.run().status).toBe(1)
const bytes = readFileSync(join(f.dir, 'token-rotation-pending.json'), 'utf8').replace(/\r\n/g, '\n')
expect(f.run().status).toBe(1)
expect(readFileSync(join(f.dir, 'token-rotation-pending.json'), 'utf8').replace(/\r\n/g, '\n')).toBe(bytes)
expect(readFileSync(f.conf, 'utf8').replace(/\r\n/g, '\n')).toContain(f.old)
} finally { rmSync(f.dir, { recursive: true, force: true }) }
})
it('persists scoped fresh-enrollment state before the POST and active config before cleanup', () => {
expect(install).not.toContain('X-EvoFW-Seed:')
expect(install).toContain('X-EvoFW-Enrollment-Credential:')
expect(install.indexOf("<<'PY_ENROLL_PENDING'")).toBeLessThan(install.indexOf('ENROLL_CODE='))
expect(install.indexOf('write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$EVOFW_CLIENT_NAME"')).toBeLessThan(install.indexOf('rm -f "$ENROLL_PENDING"'))
expect(install).toContain('0o600')
expect(install).toContain('os.fsync')
expect(linux.indexOf('ACTIVE_TOKEN=$(credential_sync)')).toBeLessThan(linux.indexOf('\nmaybe_self_update\n'))
expect(linux.indexOf('CLIENT_TOKEN="$ACTIVE_TOKEN"')).toBeLessThan(linux.indexOf('\nmaybe_self_update\n'))
})
it('RouterOS saves pending enrollment/rotation before POST and uses the persisted active token after restart', () => {
expect(router).not.toContain('X-EvoFW-Seed:')
expect(router).not.toContain('EvofwSeed')
const enroll = router.indexOf('/v1/agent/enroll')
expect(router.indexOf('contents=[:serialize $credentials', router.indexOf('"enrollment_token"'))).toBeLessThan(enroll)
const exchange = router.indexOf('/v1/agent/token-rotation')
const pending = router.indexOf(':set ($credentials->"rotation") {')
expect(router.indexOf('contents=[:serialize $credentials', pending)).toBeLessThan(exchange)
const active = router.indexOf(':set ($credentials->"active_token") ($rotation->"next_token")')
expect(active).toBeGreaterThan(router.indexOf('credential ACK mismatch'))
expect(router.indexOf('contents=[:serialize $credentials', active)).toBeLessThan(router.indexOf(':set EvofwToken ($credentials->"active_token")', active))
expect(router).toContain('exchange request still active')
expect(router).toContain(':local abandoned false')
expect(router).toContain(':if (!$abandoned) do={')
expect(router).toContain('Continue policy/telemetry using the proven old credential')
expect(router).toContain('flash/evofw-credentials.json')
})
})
@@ -0,0 +1,123 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { applyReportV2BodySchema } from '@evofw/shared'
const script = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const python = process.platform === 'win32' ? 'python' : 'python3'
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const py = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
const dirs: string[] = []
function dir() { const p = mkdtempSync(join(tmpdir(), 'evofw-dual-')); dirs.push(p); return p }
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
function embedded(name: string) { const s = script.slice(script.indexOf(`${name}() {`)); const a = s.indexOf("<<'PY'") + 7; return s.slice(a, s.indexOf('\nPY', a)).trimStart() }
function runPython(code: string, args: string[]) { return spawnSync(python, ['-c', code, ...args], { encoding: 'utf8', env: { ...process.env, EVOFW_TEST_BOOT_ID: 'dual-boot' } }) }
function normalized(patch = {}) { const d = dir(); const p = { hash: 'sha256:' + 'a'.repeat(64), generation: 1, default_action: 'accept', deny_cidrs: ['192.0.2.1', '2001:0db8:1::9/64'], allow_cidrs: ['2001:db8:2::/64'], port_rules: [], ...patch }; writeFileSync(join(d, 'candidate.json'), JSON.stringify(p)); const r = runPython(embedded('parse_policy'), [join(d, 'candidate.json'), d]); expect(r.status, r.stderr).toBe(0); return d }
function shell(content: string) { const d = dir(); writeFileSync(join(d, 'ipv6_required'), '1'); writeFileSync(join(d, 'test.sh'), `set -euo pipefail\nRUN_DIR=.; python3() { '${py}' "$@"; }\n${content}`); const r = spawnSync(bash, ['--noprofile', '--norc', join(d, 'test.sh').replaceAll('\\', '/')], { cwd: d, encoding: 'utf8' }); return { d, r } }
const ipsetFunctions = script.slice(script.indexOf('prepare_ipset_rules() {'), script.indexOf('collect_telemetry() {'))
describe('agent hardening Linux dual-stack enforcement and rollback', () => {
it('canonicalizes and collapses each IP family independently', () => {
const d = normalized({ deny_cidrs: ['10.0.0.1/25', '10.0.0.128/25', '2001:0db8::1/65', '2001:db8:0:0:8000::/65'] })
const p = JSON.parse(readFileSync(join(d, 'normalized.json'), 'utf8'))
expect(p.deny_cidrs).toEqual(['10.0.0.0/24', '2001:db8::/64'])
expect(readFileSync(join(d, 'ipv6_required'), 'utf8')).toBe('1')
})
it('emits both family sets, deny/allow enforcement, and family-scoped any-source ACL without IPv4-any IPv6 bypass', () => {
const d = normalized({ port_rules: [
{ id: 'v4-any', action: 'open', protocol: 'tcp', port_start: 443, src_cidrs: ['0.0.0.0/0'] },
{ id: 'v6-corp', action: 'open', protocol: 'tcp', port_start: 443, src_cidrs: ['2001:db8:2::/64'] },
] })
const result = runPython(embedded('render_nft_batch'), [join(d, 'normalized.json'), '1', '1', '1'])
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('deny_v6 { type ipv6_addr; flags interval; counter; }')
expect(result.stdout).toContain('add element inet evofw deny_v6 { 2001:db8:1::/64 }')
expect(result.stdout).toContain('ip6 saddr @deny_v6 counter drop')
expect(result.stdout).toContain('ip6 saddr @allow_v6 counter accept')
expect(result.stdout).toContain('meta nfproto ipv4 tcp dport 443 counter accept')
expect(result.stdout).not.toContain('prerouting fib daddr type local tcp dport 443 counter accept')
expect(result.stdout).toContain('forward ct status dnat ip6 saddr @port_src_1_v6 tcp dport 443 counter accept')
expect(result.stdout).toContain('deny_port_hits_v6 { type ipv6_addr . inet_proto . inet_service;')
expect(result.stdout).toContain('input tcp dport 443 counter drop comment "evofw-port-implicit-tcp-443"')
})
it('restores the already-applied IPv4 rules and all four sets when IPv6 rule commit fails', () => {
const { d, r } = shell(`PORT_RULES_COUNT=0; DEFAULT_ACTION=drop; DENY=(192.0.2.1/32 2001:db8::1/128); ALLOW=();\nlog() { :; }\ntype() { return 0; }\nipset() { echo "$*" >> sets; if [[ "$1" == save ]]; then if [[ "$2" == *v6 ]]; then echo "create $2 hash:net family inet6 counters"; else echo "create $2 hash:net family inet counters"; fi; elif [[ "$1" == restore ]]; then cat >sets.restore; fi; }\niptables-save() { printf '%s\\n' ':EVOFW_INPUT - [0:0]' '-A INPUT -j ACCEPT' '-A INPUT -j EVOFW_INPUT' '-A EVOFW_INPUT -j REJECT'; }\nip6tables-save() { iptables-save; }\niptables-restore() { body=$(cat); if [[ "$*" == *--test* ]]; then echo check4 >> calls; elif [[ "$body" == *'EVOFW_INPUT -j REJECT'* ]]; then echo rollback4 >> calls; else echo commit4 >> calls; fi; }\nip6tables-restore() { cat >/dev/null; if [[ "$*" == *--test* ]]; then echo check6 >> calls; else echo fail6 >> calls; return 1; fi; }\n${ipsetFunctions}\nif apply_ipset; then exit 9; fi\n`)
expect(r.status, r.stderr).toBe(0)
expect(readFileSync(join(d, 'calls'), 'utf8').trim().split(/\r?\n/)).toEqual(['check4', 'check6', 'commit4', 'fail6', 'rollback4'])
const sets = readFileSync(join(d, 'sets'), 'utf8')
for (const family of [4, 6]) for (const kind of ['deny', 'allow']) expect(sets.match(new RegExp(`swap evofw_${kind}_stage_v${family} evofw_${kind}_v${family}`, 'g'))).toHaveLength(2)
const restored = readFileSync(join(d, 'sets.restore'), 'utf8')
expect(restored).toContain('add evofw_deny_stage_v4 192.0.2.1/32')
expect(restored).toContain('add evofw_deny_stage_v6 2001:db8::1/128')
})
it('refuses unavailable IPv6 capability before touching either active family', () => {
const { d, r } = shell(`PORT_RULES_COUNT=0; DEFAULT_ACTION=accept; DENY=(2001:db8::1/128); ALLOW=();\nlog() { :; }\ntype() { return 1; }\nipset() { echo mutation >> mutations; }\n${ipsetFunctions}\nif apply_ipset; then exit 9; fi\nprintf '%s' "$APPLY_ERROR_CODE" >code\n`)
expect(r.status, r.stderr).toBe(0)
expect(readFileSync(join(d, 'code'), 'utf8')).toBe('UNSUPPORTED_CAPABILITY')
expect(() => readFileSync(join(d, 'mutations'))).toThrow()
})
it('rejects an IPv6 owned jump preempted by a foreign rule during drift verification', () => {
const d = dir(); writeFileSync(join(d, 'live-ipset.txt'), 'create evofw_deny_v4 hash:net family inet\ncreate evofw_deny_v6 hash:net family inet6\n')
writeFileSync(join(d, 'live-iptables.txt'), '-A INPUT -j EVOFW_INPUT\n')
writeFileSync(join(d, 'live-iptables-v6.txt'), '-A INPUT -j ACCEPT\n-A INPUT -j EVOFW_INPUT\n')
const r = runPython(embedded('kernel_digest'), ['ipset', d])
expect(r.status).not.toBe(0); expect(r.stderr).toContain('owned INPUT jump is missing or preempted')
})
it('collects canonical IPv6 host/port counters and advertises only observed implemented capabilities', () => {
const d = dir(); expect(runPython(embedded('telemetry_worker'), [d, d, 'init']).status).toBe(0)
writeFileSync(join(d, 'metrics-nft.json'), JSON.stringify({ nftables: [
{ set: { family: 'inet', table: 'evofw', name: 'allow_v6', elem: [] } },
{ set: { family: 'inet', table: 'evofw', name: 'deny_v6', elem: [{ elem: { val: { prefix: { addr: '2001:0db8::1', len: 128 } }, counter: { packets: 9 } } }] } },
{ set: { family: 'inet', table: 'evofw', name: 'deny_port_hits_v6', elem: [{ elem: { val: { concat: ['2001:0db8::1', 'tcp', 443] }, counter: { packets: 8 } } }] } },
{ rule: { family: 'inet', table: 'evofw', chain: 'input', comment: 'evofw-v2/input/deny6', expr: [{ counter: { packets: 9 } }, { drop: null }] } },
] }))
expect(runPython(embedded('telemetry_worker'), [d, d, 'capture', 'nft']).status).toBe(0)
expect(runPython(embedded('telemetry_worker'), [d, d, 'enqueue', 'ok', 'nft']).status).toBe(0)
const state = JSON.parse(readFileSync(join(d, 'telemetry-state.json'), 'utf8')); const report = JSON.parse(state.pending[0])
expect(report.ip_hits).toEqual([{ ip: '2001:db8::1', packets: 9 }])
expect(report.port_hits).toEqual([{ ip: '2001:db8::1', protocol: 'tcp', port: 443, packets: 8 }])
expect(report.capabilities).toEqual(expect.arrayContaining(['policy-ipv6', 'policy-port-acl-ipv6', 'ip-counters-ipv6', 'port-counters-ipv6']))
expect(applyReportV2BodySchema.safeParse(report).success).toBe(true)
})
it('omits aggregate totals when the active IPv6 family read fails, then recovers both-family totals', () => {
const d = dir(); expect(runPython(embedded('telemetry_worker'), [d, d, 'init']).status).toBe(0)
expect(runPython(embedded('telemetry_worker'), [d, d, 'rotate', '4,6']).status).toBe(0)
writeFileSync(join(d, 'metrics-iptables.txt'), '[5:100] -A EVOFW_INPUT -m set --match-set evofw_deny_v4 src -j DROP\n[2:100] -A EVOFW_INPUT -m set --match-set evofw_allow_v4 src -j ACCEPT\n')
expect(runPython(embedded('telemetry_worker'), [d, d, 'capture', 'ipset']).status).toBe(0)
const missing = JSON.parse(readFileSync(join(d, 'snapshot.json'), 'utf8'))
expect(missing).not.toHaveProperty('packets_dropped')
expect(missing).not.toHaveProperty('packets_accepted')
expect(missing.quality.partial).toBe(true)
expect(missing.quality.unsupported).toContain('ip6tables_counters_unavailable')
writeFileSync(join(d, 'metrics-iptables-v6.txt'), '[7:100] -A EVOFW_INPUT -m set --match-set evofw_deny_v6 src -j DROP\n[3:100] -A EVOFW_INPUT -m set --match-set evofw_allow_v6 src -j ACCEPT\n')
expect(runPython(embedded('telemetry_worker'), [d, d, 'capture', 'ipset']).status).toBe(0)
const recovered = JSON.parse(readFileSync(join(d, 'snapshot.json'), 'utf8'))
expect(recovered.packets_dropped).toBe(12)
expect(recovered.packets_accepted).toBe(5)
expect(recovered.quality.unsupported).not.toContain('ip6tables_counters_unavailable')
})
it('preserves complete part baselines across missing-family reports and catches reset masked by other-family growth', () => {
const d = dir(); expect(runPython(embedded('telemetry_worker'), [d, d, 'init']).status).toBe(0)
expect(runPython(embedded('telemetry_worker'), [d, d, 'rotate', '4,6']).status).toBe(0)
const v4 = (n: number) => writeFileSync(join(d, 'metrics-iptables.txt'), `[${n}:100] -A EVOFW_INPUT -m set --match-set evofw_deny_v4 src -j DROP\n`)
const v6 = (n: number) => writeFileSync(join(d, 'metrics-iptables-v6.txt'), `[${n}:100] -A EVOFW_INPUT -m set --match-set evofw_deny_v6 src -j DROP\n`)
const emit = () => {
expect(runPython(embedded('telemetry_worker'), [d, d, 'capture', 'ipset']).status).toBe(0)
expect(runPython(embedded('telemetry_worker'), [d, d, 'enqueue', 'ok', 'ipset']).status).toBe(0)
return JSON.parse(readFileSync(join(d, 'telemetry-state.json'), 'utf8'))
}
v4(5); v6(7); const before = emit()
v4(20); rmSync(join(d, 'metrics-iptables-v6.txt')); const missing = emit()
expect(missing.last_parts.traffic).toEqual(before.last_parts.traffic)
expect(JSON.parse(missing.pending[1])).not.toHaveProperty('packets_dropped')
v6(1); const recovered = emit()
const reports = recovered.pending.map((bytes: string) => JSON.parse(bytes))
expect(reports[2].packets_dropped).toBe(21)
expect(reports[2].packets_accepted).toBe(0)
expect(reports[2].counter_epoch.traffic).not.toBe(reports[0].counter_epoch.traffic)
expect(recovered.last_parts.traffic['v6/-A EVOFW_INPUT -m set --match-set evofw_deny_v6 src -j DROP']).toBe(1)
})
})
@@ -0,0 +1,141 @@
import { linkEnrollmentCredential } from './enrollment.js'
import { readFileSync } from 'node:fs'
import { afterAll, describe, expect, it } from 'vitest'
import { applyReportV2BodySchema } from '@evofw/shared'
import { repos } from '@evofw/db'
import { hashToken } from '../plugins/auth.js'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
const config: AppConfig = { databaseUrl: 'sqlite::memory:', jwtSecret: 'test', jwtTtlHours: 24, serverPort: 8080, staticDir: null, logLevel: 'error', authRequired: false, authIssuer: 'https://auth.test', authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30 }
describe('RouterOS dual-stack endpoint and imported-metadata contracts (no RouterOS runtime)', () => {
const appPromise = buildApp({ memory: true, config })
afterAll(async () => (await appPromise).close())
it('ACKs actual compiled hash/revision after a JSON-to-RSC generation race', async () => {
const app = await appPromise
const link = await app.inject({ method: 'POST', url: '/api/v1/install-links', payload: { name: 'rsc-race', platform: 'mikrotik' } })
expect(link.statusCode).toBe(201)
const { id, agent_id: agentId } = link.json()
const token = 'evofw_rsc_dualstack_race_token_12345678'
expect((await app.inject({ method: 'POST', url: '/v1/agent/enroll', headers: { 'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, id)!, config.enrollSeed) }, payload: { name: 'rsc-race', platform: 'mikrotik', token, install_link_id: id } })).statusCode).toBe(201)
await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/approve` })
const headers = { authorization: `Bearer ${token}` }
const before = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers })
expect(before.statusCode).toBe(200)
expect((await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/overrides`, payload: { action: 'deny', cidr: '2001:db8:7::99/64' } })).statusCode).toBe(200)
const compiled = await app.inject({ method: 'GET', url: '/v1/agent/policy.rsc', headers })
expect(compiled.statusCode).toBe(200)
const renderedHash = compiled.body.match(/^:set EvofwRenderedHash "(sha256:[a-f0-9]{64})"$/m)?.[1]
const renderedRevision = Number(compiled.body.match(/^:set EvofwRenderedRevision ([0-9]+)$/m)?.[1])
expect(renderedHash).toBeTruthy()
expect(renderedHash).not.toBe(before.json().hash)
expect(renderedRevision).toBeGreaterThan(before.json().generation)
expect(compiled.body).toContain('/ipv6 firewall address-list add list=$deny6 address=2001:db8:7::/64')
const importAt = installer.indexOf('/import file-name=$file')
const importAcknowledgement = installer.slice(importAt, installer.indexOf(':set syncOk 1', importAt))
expect(installer.lastIndexOf(':set EvofwRenderedHash ""', importAt)).toBeGreaterThan(0)
expect(importAcknowledgement).toContain(':set EvofwLastHash $EvofwRenderedHash')
expect(importAcknowledgement).toContain(':set EvofwAppliedRevision $EvofwRenderedRevision')
expect(importAcknowledgement).not.toContain(':set EvofwLastHash $hash')
const report = { telemetry_version: 2, report_id: 'rsc-race-ack', sequence: 1, boot_id: 'rsc-boot', observed_at: new Date().toISOString(), interval_ms: 60000, counter_epoch: { traffic: 'rsc-epoch' }, agent_version: 'rsc/9', source: 'mikrotik', status: 'ok', kernel_method: 'address-list', applied_hash: renderedHash, applied_revision: renderedRevision, capabilities: ['telemetry-v2', 'policy-ipv4', 'policy-ipv6', 'mikrotik-staged-apply'], quality: { partial: true, truncated: false, unsupported: ['ip_counters_presence_only', 'port_counters_unavailable'], lost_reports: 0 } }
expect(applyReportV2BodySchema.safeParse(report).success).toBe(true)
const ack = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: report })
expect(ack.statusCode, ack.body).toBe(200)
expect(ack.json()).toMatchObject({ ok: true, accepted: true, report_id: report.report_id, sequence: 1 })
const agent = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}` })
expect(agent.json().diagnostics).toMatchObject({ applied_hash: renderedHash, applied_revision: renderedRevision, status: 'in_sync' })
})
it('retains final-snapshot pending payload on failed ACK and gates mutation on capability before import', () => {
const policyFetch = installer.indexOf('/v1/agent/policy?transport=routeros")')
const unsupported = installer.indexOf('IPv6 enforcement unavailable', policyFetch)
const importAt = installer.indexOf('/import file-name=$file')
expect(unsupported).toBeGreaterThan(policyFetch)
expect(unsupported).toBeLessThan(importAt)
expect(installer).toContain(':set EvofwApplyErrorCode "UNSUPPORTED_CAPABILITY"')
expect(installer).toContain('final telemetry ACK pending; defer counter-resetting apply')
expect(installer).toContain(':if ([:len ($telemetryState->"pending")] > 0) do={ :return }')
expect(installer.indexOf('contents=[:serialize $telemetryState', installer.indexOf(':local final '))).toBeLessThan(importAt)
expect(installer).not.toContain('/v1/agent/heartbeat')
})
it('aggregates both families conservatively and never converts address presence into packets', () => {
expect(installer).toContain(':local readCounters do={')
expect(installer).toContain('/ipv6 firewall filter get $id packets')
expect(installer).toContain('} on-error={ :set valid false }')
expect(installer).toContain(':if (!$valid) do={ :return {"valid"=false} }')
expect(installer).toContain(':if (($counters->"valid") = true)')
expect(installer).toContain('traffic_counters_unavailable')
expect(installer).toContain('"policy-ipv6";"traffic-counters-ipv6"')
expect(installer).not.toContain('"ip_hits"=')
expect(installer).not.toContain('"port_hits"=')
})
it('rejects a narrowed default DROP in each family before declaring a verified no-op', () => {
const drift = installer.slice(installer.indexOf(':local drift 0'), installer.indexOf(':if ($hash = $EvofwLastHash'))
for (const family of ['ip', 'ipv6']) {
const start = drift.indexOf(`:local rule [/${family} firewall filter find comment=evofw-default-drop-forward]`)
const block = drift.slice(start, drift.indexOf(':local ordered', start))
expect(block).toContain(`:if ([/${family} firewall filter get $rule src-address-list] != "") do={ :error "owned default matcher drift" }`)
const permitted = block.match(/:local permitted \{([^}]+)\}/)?.[1]
for (const narrowing of ['src-address', 'dst-address', 'dst-address-list', 'protocol', 'dst-port', 'in-interface', 'connection-state']) {
expect(permitted).not.toContain(`"${narrowing}"`)
}
expect(block).toContain('owned matcher drift')
}
})
it('retains complete per-rule reset baselines across unavailable observations and shares detection with final snapshots', () => {
const read = installer.slice(installer.indexOf(':local readCounters do={'), installer.indexOf(':local syncOk 0'))
for (const family of ['ip', 'ipv6']) {
for (const rule of ['deny-drop-input', 'deny-drop-forward', 'default-drop-forward', 'allow-accept-forward']) {
expect(read).toContain(`:set ($observed->"${family}:evofw-${rule}") $n`)
}
}
// A single reset is visible even if another family or rule grows faster.
expect(read).toContain(':foreach key,value in=$observed do={')
expect(read).toContain(':local previous ($EvofwRuleCounters->$key)')
expect(read).toContain('$value < $previous')
expect(read.indexOf(':if (!$valid)')).toBeLessThan(read.indexOf(':set EvofwRuleCounters $observed'))
expect(read).toContain(':if ($reset) do={ :set EvofwTrafficEpoch [:rndstr length=32] }')
expect(installer).not.toContain('EvofwLastDropped')
expect(installer).not.toContain('EvofwLastAccepted')
const finalStart = installer.indexOf(':local finalCounters [$readCounters]')
expect(finalStart).toBeLessThan(installer.indexOf(':local final {'))
expect(installer).toContain('($finalCounters->"reset") = true')
expect(installer).toContain('($counters->"reset") = true')
expect(installer.match(/counter_reset_coverage_unknown/g)).toHaveLength(2)
const importAt = installer.indexOf('/import file-name=$file')
expect(installer.indexOf(':set EvofwRuleCounters [:toarray ""]', importAt)).toBeGreaterThan(importAt)
})
it('distinguishes the opt-in budget envelope from valid policy and authentication failure before import', async () => {
const app = await appPromise
const token = 'rsc-budget-transport-token'
repos.insertAgent(app.db, { id: 'rsc-budget', name: 'rsc-budget', tokenPrefix: 'rsc', tokenHash: hashToken(token), status: 'approved' })
repos.ensureSharedSetAssigned(app.db, 'rsc-budget')
const headers = { authorization: `Bearer ${token}` }
const valid = await app.inject({ url: '/v1/agent/policy?transport=routeros', headers })
expect(valid.statusCode).toBe(200); expect(valid.json()).toHaveProperty('hash'); expect(valid.json()).not.toHaveProperty('error')
const insert = app.sqlite.prepare("INSERT INTO agent_port_rules(id,agent_id,action,protocol,port_start,port_end,src_kind) VALUES(?,'rsc-budget','open','both',80,80,'all')")
app.sqlite.transaction(() => { for (let i = 0; i < 501; i++) insert.run(`rsc-budget-${i}`) })()
repos.bumpAgentGeneration(app.db, 'rsc-budget')
const rejected = await app.inject({ url: '/v1/agent/policy?transport=routeros', headers })
expect(rejected.statusCode).toBe(200); expect(rejected.json().error.code).toBe('POLICY_BUDGET_EXCEEDED')
expect(rejected.json()).not.toHaveProperty('hash'); expect(rejected.json()).not.toHaveProperty('generation')
expect(rejected.headers.etag).toBeUndefined(); expect(rejected.headers['cache-control']).toBe('no-store')
expect((await app.inject({ url: '/v1/agent/policy?transport=routeros', headers: { authorization: 'Bearer wrong' } })).statusCode).toBe(401)
const envelopeAt = installer.indexOf(':if ([:typeof ($p->"error")] = "array")')
expect(envelopeAt).toBeGreaterThan(0)
expect(envelopeAt).toBeLessThan(installer.indexOf(':local hash ($p->"hash")'))
expect(installer).toContain('unexpected policy error envelope')
expect(installer).not.toContain('[:typeof ($p->"error")] != "nothing"')
expect(installer.indexOf('policy fetch failed')).toBeLessThan(envelopeAt)
})
it('keeps native stale-floor recovery bounded and selects persistent flash storage when present', () => {
expect(installer).toContain('$flashType = "disk" || $flashType = "directory"')
expect(installer).toContain('flash/evofw-telemetry.json')
expect(installer).toContain('$floor < ($expected->"sequence") || $floor >= 9007199254740991')
expect(installer).toContain(':set ($state->"next_sequence") ($EvofwSequenceFloor + 1)')
expect(installer).toContain(':set ($state->"coverage_unknown") true')
expect(installer).toContain(':set EvofwTrafficEpoch [:rndstr length=32]')
expect(installer).toContain('sequence_rollback_coverage_unknown')
expect(installer.match(/\$resyncSequence state=\$telemetryState/g)).toHaveLength(3)
})
})
@@ -0,0 +1,317 @@
import { repos } from '@evofw/db'
import { linkEnrollmentCredential } from './enrollment.js'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, afterEach, describe, expect, it } from 'vitest'
import { applyReportV2BodySchema } from '@evofw/shared'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const source = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const python = process.platform === 'win32' ? 'python' : 'python3'
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const dirs: string[] = []
function workspace() { const dir = mkdtempSync(join(tmpdir(), 'evofw-v2-test-')); dirs.push(dir); return dir }
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
function embedded(name: string) { const s = source.slice(source.indexOf(`${name}() {`)); const begin = s.indexOf("<<'PY'") + 7; return s.slice(begin, s.indexOf('\nPY', begin)).trimStart() }
function worker(dir: string, op: string, ...args: string[]) {
return spawnSync(python, ['-c', embedded('telemetry_worker'), dir, dir, op, ...args], { encoding: 'utf8', env: { ...process.env, EVOFW_TEST_BOOT_ID: 'fixture-boot' } })
}
function state(dir: string) { return JSON.parse(readFileSync(join(dir, 'telemetry-state.json'), 'utf8')) }
function pending(dir: string) { return JSON.parse(state(dir).pending[0]) }
function initialize() { const dir = workspace(); const r = worker(dir, 'init'); expect(r.status, r.stderr).toBe(0); return dir }
function rule(chain: string, id: string, verdict: 'drop' | 'accept', packets: number) { return { rule: { family: 'inet', table: 'evofw', chain, comment: `evofw-v2/${id}`, expr: [{ counter: { packets, bytes: 0 } }, { [verdict]: null }] } } }
function snapshot(dir: string, entries: unknown[]) { writeFileSync(join(dir, 'metrics-nft.json'), JSON.stringify({ nftables: entries })); const r = worker(dir, 'capture', 'nft'); expect(r.status, r.stderr).toBe(0) }
function queue(dir: string) { const r = worker(dir, 'enqueue', 'ok', 'nft'); expect(r.status, r.stderr).toBe(0); return pending(dir) }
const config: AppConfig = { databaseUrl: 'sqlite::memory:', jwtSecret: 'test', jwtTtlHours: 24, serverPort: 8080, staticDir: null, logLevel: 'error', authRequired: false, authIssuer: 'https://auth.test', authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30 }
describe('agent hardening real endpoint contracts', () => {
const appPromise = buildApp({ memory: true, config })
afterAll(async () => (await appPromise).close())
let enrollment = 0
async function enroll(platform: 'linux' | 'mikrotik') {
const app = await appPromise
const token = `agent-hardening-${platform}-token-${++enrollment}-12345678`
const link = await app.inject({ method: 'POST', url: '/api/v1/install-links', payload: { name: `hardening-${platform}-${enrollment}`, platform } })
expect(link.statusCode).toBe(201)
const { id, agent_id: agentId } = link.json()
const enrolled = await app.inject({ method: 'POST', url: '/v1/agent/enroll', headers: { 'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, id)!, config.enrollSeed) }, payload: { name: `hardening-${platform}-${enrollment}`, platform, token, install_link_id: id } })
expect(enrolled.statusCode).toBe(201)
await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/approve` })
return { app, token, agentId, headers: { authorization: `Bearer ${token}` } }
}
it('passes actual API policy through the Bash parser and checked nft apply arguments', async () => {
const { app, headers, agentId } = await enroll('linux')
const set = await app.inject({ method: 'POST', url: '/api/v1/policy-sets', payload: { name: 'agent-contract-set' } })
expect(set.statusCode).toBe(200)
for (const [action, cidr] of [['deny', '10.23.1.9/24'], ['allow', '192.0.2.9/32']]) {
const rule = await app.inject({ method: 'POST', url: '/api/v1/rules', payload: { set_id: set.json().id, action, cidr } })
expect(rule.statusCode).toBe(200)
}
expect((await app.inject({ method: 'PUT', url: `/api/v1/agents/${agentId}/policy-sets`, payload: { set_ids: [set.json().id] } })).statusCode).toBe(200)
expect((await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/port-rules`, payload: { action: 'open', protocol: 'both', port_start: 443, src_kind: 'cidr', src_cidr: '192.0.2.0/24' } })).statusCode).toBe(200)
const response = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers })
expect(response.statusCode).toBe(200)
const actual = response.json()
expect(actual.hash).toMatch(/^sha256:[0-9a-f]{64}$/)
expect(actual.deny_cidrs).toEqual(['10.23.1.0/24'])
expect(actual.port_rules).toHaveLength(2)
const dir = workspace(); writeFileSync(join(dir, 'policy.json'), response.body)
const pythonPath = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
const parse = source.slice(source.indexOf('parse_policy() {'), source.indexOf('HASH="";'))
const render = source.slice(source.indexOf('render_nft_batch() {'), source.indexOf('# Collect observed host firewall'))
writeFileSync(join(dir, 'test.sh'), `set -euo pipefail\nRUN_DIR=.; LOG_FILE=log; PORT_RULES_FILE=./port_rules.json; DENY=(); ALLOW=();\npython3() { '${pythonPath}' "$@"; }\nlog() { :; }\nnft() { printf '%s\\n' "$*" >>calls; if [[ "$1" == '--check' ]]; then cp "$3" checked.batch; elif [[ "$1" == '--file' ]]; then cmp checked.batch "$2"; fi; }\n${parse}\n${render}\nparse_policy policy.json\napply_nft\nprintf '%s\\n' "$HASH" "$DEFAULT_ACTION" "$APPLIED" > parsed.txt\n`)
const run = spawnSync(bash, ['--noprofile', '--norc', join(dir, 'test.sh').replaceAll('\\', '/')], { cwd: dir, encoding: 'utf8' })
expect(run.status, run.stderr).toBe(0)
const lines = readFileSync(join(dir, 'parsed.txt'), 'utf8').trim().split(/\r?\n/)
expect(lines).toEqual([actual.hash, actual.default_action, String(actual.deny_cidrs.length + actual.allow_cidrs.length)])
expect(readFileSync(join(dir, 'calls'), 'utf8')).toContain('--check --file ./nft.batch')
expect(readFileSync(join(dir, 'checked.batch'), 'utf8')).toContain('add element inet evofw deny_v4 { 10.23.1.0/24 }')
expect(readFileSync(join(dir, 'checked.batch'), 'utf8')).toContain('forward ct status dnat ip saddr @port_src_0_v4 tcp dport 443')
})
it('acknowledges a newer generation with identical hash after verified no-op without changing epochs or pending bytes', async () => {
const { app, headers, agentId } = await enroll('linux')
const firstResponse = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers })
const first = firstResponse.json()
const dir = initialize()
writeFileSync(join(dir, 'last_policy.json'), firstResponse.body)
snapshot(dir, [rule('input', 'input/drop', 'drop', 7)])
queue(dir)
const original = state(dir)
const bumped = await app.inject({ method: 'PUT', url: `/api/v1/agents/${agentId}/policy-sets`, payload: { set_ids: [] } })
expect(bumped.statusCode).toBe(200)
const nextResponse = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers })
const next = nextResponse.json()
expect(next.hash).toBe(first.hash)
expect(next.generation).toBeGreaterThan(first.generation)
writeFileSync(join(dir, 'candidate.json'), nextResponse.body)
writeFileSync(join(dir, 'last_hash'), first.hash)
writeFileSync(join(dir, 'kernel_state'), 'nft verified\n')
const pythonPath = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
const workerFunctions = source.slice(source.indexOf('telemetry_worker() {'), source.indexOf('telemetry_worker init'))
const parse = source.slice(source.indexOf('parse_policy() {'), source.indexOf('HASH="";'))
const noChange = source.slice(source.indexOf('KERNEL_STATE_FILE="$STATE_DIR/kernel_state"'), source.indexOf('# Durably queue the final old-epoch'))
writeFileSync(join(dir, 'no-change.sh'), `set -euo pipefail\nSTATE_DIR=.; RUN_DIR=.; HASH_FILE=last_hash; BACKEND=nft; DENY=(); ALLOW=();\npython3() { '${pythonPath}' "$@"; }\nlog() { :; }\nkernel_digest() { echo verified; }\napply_nft() { echo unexpected >> applied; }\n${workerFunctions}\n${parse}\nsend_report() { telemetry_worker enqueue ok nft; }\nparse_policy candidate.json\n${noChange}`)
const result = spawnSync(bash, ['--noprofile', '--norc', join(dir, 'no-change.sh').replaceAll('\\', '/')], { cwd: dir, encoding: 'utf8' })
expect(result.status, result.stderr).toBe(0)
const updated = state(dir)
expect(updated.epochs).toEqual(original.epochs)
expect(updated.pending[0]).toBe(original.pending[0])
expect(JSON.parse(updated.pending[1]).applied_revision).toBe(next.generation)
expect(JSON.parse(readFileSync(join(dir, 'last_policy.json'), 'utf8')).generation).toBe(next.generation)
expect(() => readFileSync(join(dir, 'applied'))).toThrow()
for (const bytes of updated.pending) {
const response = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: JSON.parse(bytes) })
expect(response.statusCode, response.body).toBe(200)
}
const agent = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}` })
expect(agent.json().diagnostics.applied_revision).toBe(next.generation)
expect(agent.json().diagnostics.status).toBe('in_sync')
})
it('compiles mixed IPv4/IPv6 policy from the real API without cross-family ACL bypass', async () => {
const { app, headers, agentId } = await enroll('linux')
for (const cidr of ['192.0.2.1/32', '2001:db8:1::99/64']) {
const response = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/overrides`, payload: { action: 'deny', cidr } })
expect(response.statusCode).toBe(200)
}
const port = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/port-rules`, payload: { action: 'open', protocol: 'tcp', port_start: 443, src_kind: 'cidr', src_cidr: '2001:db8:2::/64' } })
expect(port.statusCode).toBe(200)
const response = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers })
expect(response.statusCode).toBe(200)
const dir = workspace(); writeFileSync(join(dir, 'candidate.json'), response.body)
const parsed = spawnSync(python, ['-c', embedded('parse_policy'), join(dir, 'candidate.json'), dir], { encoding: 'utf8' })
expect(parsed.status, parsed.stderr).toBe(0)
const rendered = spawnSync(python, ['-c', embedded('render_nft_batch'), join(dir, 'normalized.json'), '1', '1', '1'], { encoding: 'utf8' })
expect(rendered.status, rendered.stderr).toBe(0)
expect(rendered.stdout).toContain('add element inet evofw deny_v4 { 192.0.2.1/32 }')
expect(rendered.stdout).toContain('add element inet evofw deny_v6 { 2001:db8:1::/64 }')
expect(rendered.stdout).toContain('forward ct status dnat ip6 saddr @port_src_0_v6 tcp dport 443 counter accept')
expect(rendered.stdout).not.toContain('ip saddr @port_src_0_v6')
})
it('renders staged RouterOS script from actual authenticated policy.rsc endpoint', async () => {
const { app, headers } = await enroll('mikrotik')
const response = await app.inject({ method: 'GET', url: '/v1/agent/policy.rsc', headers })
expect(response.statusCode).toBe(200)
expect(response.body).toContain(':local deny4 ("EVOFW_DENY_" . $tx)')
expect(response.body).toContain('owned filter chain/action drift')
expect(response.body).toContain('owned filter order drift')
expect(response.body).toContain('/ip firewall filter remove $new4')
})
it('roundtrips Python-emitted v2 report through the shared schema, ingest, ACK and replay', async () => {
const { app, headers } = await enroll('linux')
const dir = initialize()
const policy = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers })
writeFileSync(join(dir, 'last_policy.json'), policy.body)
snapshot(dir, [rule('input', 'input/drop', 'drop', 10)])
const report = queue(dir)
expect(applyReportV2BodySchema.safeParse(report).success).toBe(true)
const accepted = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: report })
expect(accepted.statusCode, accepted.body).toBe(200)
expect(accepted.json()).toMatchObject({ ok: true, accepted: true, report_id: report.report_id, sequence: report.sequence })
const replay = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: report })
expect(replay.json()).toMatchObject({ ok: true, duplicate: true, report_id: report.report_id })
writeFileSync(join(dir, 'report-ack.json'), replay.body)
expect(worker(dir, 'ack').status).toBe(0)
expect(state(dir).pending).toHaveLength(0)
})
it('recovers rolled-back durable sequence through an authenticated API floor without replaying invented deltas', async () => {
const { app, headers } = await enroll('linux')
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5)])
const old = queue(dir); const immutable = state(dir).pending[0]
const newer = { ...old, report_id: 'server-already-accepted-100', sequence: 100 }
expect((await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: newer })).json()).toMatchObject({ accepted: true, sequence_floor: 100 })
const stale = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: old })
expect(stale.statusCode, stale.body).toBe(200)
expect(stale.json()).toMatchObject({ reason: 'stale_sequence', sequence_floor: 100, report_id: old.report_id, sequence: 0 })
expect(state(dir).pending[0]).toBe(immutable)
writeFileSync(join(dir, 'report-ack.json'), stale.body)
expect(worker(dir, 'ack').status).toBe(0)
expect(state(dir)).toMatchObject({ next_sequence: 101, coverage_unknown: true, last_parts: {} })
snapshot(dir, [rule('input', 'input/drop', 'drop', 8)])
const recovered = queue(dir)
expect(recovered.sequence).toBe(101)
expect(recovered.report_id).not.toBe(old.report_id)
expect(recovered.counter_epoch.traffic).not.toBe(old.counter_epoch.traffic)
expect(recovered.quality).toMatchObject({ partial: true })
expect(recovered.quality.unsupported).toContain('sequence_rollback_coverage_unknown')
expect(applyReportV2BodySchema.safeParse(recovered).success).toBe(true)
expect((await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload: recovered })).json()).toMatchObject({ accepted: true, sequence_floor: 101 })
})
})
describe('agent hardening telemetry v2 durable state and collectors', () => {
it('does not double-count prerouting accepts or fabricate missing metrics', () => {
const dir = initialize()
snapshot(dir, [rule('prerouting', 'pre/accept', 'accept', 100), rule('prerouting', 'pre/drop', 'drop', 4), rule('input', 'input/accept', 'accept', 8), rule('forward', 'forward/drop', 'drop', 3)])
const report = queue(dir)
expect(report.packets_accepted).toBe(8)
expect(report.packets_dropped).toBe(7)
expect(report).not.toHaveProperty('ip_hits')
expect(report.quality.unsupported).toContain('ip_counters_unavailable')
expect(applyReportV2BodySchema.safeParse(report).success).toBe(true)
})
it('keeps queued bytes and ID stable across restart and rejects mismatched ACK', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5)]); const report = queue(dir)
const bytes = state(dir).pending[0]
expect(worker(dir, 'init').status).toBe(0)
expect(state(dir).pending[0]).toBe(bytes)
writeFileSync(join(dir, 'report-ack.json'), JSON.stringify({ ok: true, accepted: true, report_id: 'wrong', sequence: report.sequence }))
expect(worker(dir, 'ack').status).not.toBe(0)
expect(state(dir).pending[0]).toBe(bytes)
writeFileSync(join(dir, 'report-ack.json'), JSON.stringify({ ok: true, accepted: true, report_id: report.report_id, sequence: report.sequence }))
expect(worker(dir, 'ack').status).toBe(0)
queue(dir)
expect(pending(dir).sequence).toBe(report.sequence + 1)
})
it('refuses malformed stale floors and stops sync at the authenticated maximum without discarding pending bytes', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5)]); const report = queue(dir)
const original = readFileSync(join(dir, 'telemetry-state.json'), 'utf8')
for (const floor of [undefined, -1, 1.5, '100', Number.MAX_SAFE_INTEGER + 1]) {
writeFileSync(join(dir, 'report-ack.json'), JSON.stringify({ ok: true, reason: 'stale_sequence', report_id: report.report_id, sequence: report.sequence, sequence_floor: floor }))
expect(worker(dir, 'ack').status).not.toBe(0)
expect(readFileSync(join(dir, 'telemetry-state.json'), 'utf8')).toBe(original)
}
writeFileSync(join(dir, 'report-ack.json'), JSON.stringify({ ok: true, reason: 'stale_sequence', report_id: report.report_id, sequence: report.sequence, sequence_floor: Number.MAX_SAFE_INTEGER }))
expect(worker(dir, 'ack').status).not.toBe(0)
expect(state(dir).pending).toEqual(JSON.parse(original).pending)
expect(state(dir).sequence_exhausted).toBe(true)
expect(worker(dir, 'assert-sequence').status).not.toBe(0)
expect(worker(dir, 'enqueue', 'ok', 'nft').status).not.toBe(0)
const startup = source.slice(source.indexOf('telemetry_worker init'), source.indexOf('curl_policy() {'))
expect(startup.indexOf('flush_reports')).toBeLessThan(startup.indexOf('telemetry_worker assert-sequence'))
expect(startup.indexOf('telemetry_worker assert-sequence')).toBeLessThan(startup.indexOf('maybe_self_update'))
})
it('detects counter reset with an epoch transition, preserving queued old epoch', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 50)]); queue(dir)
snapshot(dir, [rule('input', 'input/drop', 'drop', 2)]); queue(dir)
const reports = state(dir).pending.map((s: string) => JSON.parse(s))
expect(reports[1].counter_epoch.traffic).not.toBe(reports[0].counter_epoch.traffic)
expect(reports[1].sequence).toBe(reports[0].sequence + 1)
expect(reports[0].packets_dropped).toBe(50)
})
it('omits unsafe integer totals and labels degraded quality', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', Number.MAX_SAFE_INTEGER), rule('forward', 'forward/drop', 'drop', 1)])
const report = queue(dir)
expect(report).not.toHaveProperty('packets_dropped')
expect(report).not.toHaveProperty('packets_accepted')
expect(report.quality.unsupported).toContain('traffic_counter_unsafe')
expect(applyReportV2BodySchema.safeParse(report).success).toBe(true)
})
it('parses measured host/port elements, omits prefix aggregates masquerading as hosts', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5),
{ set: { family: 'inet', table: 'evofw', name: 'deny_v4', elem: [{ elem: { val: '192.0.2.1', counter: { packets: 5 } } }, { elem: { val: { prefix: { addr: '10.0.0.0', len: 24 } }, counter: { packets: 100 } } }] } },
{ set: { family: 'inet', table: 'evofw', name: 'deny_port_hits', elem: [{ elem: { val: { concat: ['192.0.2.1', 'tcp', 443] }, counter: { packets: 4 } } }] } },
])
const report = queue(dir)
expect(report.ip_hits).toEqual([{ ip: '192.0.2.1', packets: 5 }])
expect(report.port_hits).toEqual([{ ip: '192.0.2.1', port: 443, protocol: 'tcp', packets: 4 }])
expect(report.detail_counts).toEqual({ ip_found: 1, ip_sent: 1, port_found: 1, port_sent: 1 })
expect(report.quality.unsupported).toContain('ip_prefix_counters')
expect(applyReportV2BodySchema.safeParse(report).success).toBe(true)
})
it('bounds the queue and preserves every pending report when full', () => {
const dir = initialize(); const before = state(dir)
before.pending = Array.from({ length: 32 }, () => '{"persisted":true}'); writeFileSync(join(dir, 'telemetry-state.json'), JSON.stringify(before))
expect(worker(dir, 'enqueue', 'ok', 'nft').status).not.toBe(0)
expect(state(dir).pending).toEqual(before.pending)
expect(state(dir).lost_reports).toBe(1)
})
it('rotates boot epochs without rewriting pending old-boot reports', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5)]); const report = queue(dir)
const result = spawnSync(python, ['-c', embedded('telemetry_worker'), dir, dir, 'init'], { encoding: 'utf8', env: { ...process.env, EVOFW_TEST_BOOT_ID: 'next-boot' } })
expect(result.status, result.stderr).toBe(0)
expect(pending(dir)).toEqual(report)
expect(state(dir).epochs.traffic).not.toBe(report.counter_epoch.traffic)
expect(state(dir).next_sequence).toBe(report.sequence + 1)
})
it('does not allocate a sequence or lose pending reports if persistence fails', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5)]); queue(dir)
const before = readFileSync(join(dir, 'telemetry-state.json'), 'utf8')
mkdirSync(join(dir, 'telemetry-state.json.new'))
expect(worker(dir, 'enqueue', 'ok', 'nft').status).not.toBe(0)
expect(readFileSync(join(dir, 'telemetry-state.json'), 'utf8')).toBe(before)
})
it('keeps the exact pending report after a simulated HTTP timeout', () => {
const dir = initialize(); snapshot(dir, [rule('input', 'input/drop', 'drop', 5)]); queue(dir)
const before = state(dir).pending[0]
const workerFunction = source.slice(source.indexOf('telemetry_worker() {'), source.indexOf('telemetry_worker init'))
const pythonPath = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
writeFileSync(join(dir, 'timeout.sh'), `set -euo pipefail\nSTATE_DIR=.; RUN_DIR=.; EVOFW_CP_URL=https://unused.invalid; CLIENT_TOKEN=fixture;\npython3() { '${pythonPath}' "$@"; }\ncurl() { return 28; }\nlog() { :; }\n${workerFunction}\nflush_reports\n`)
const result = spawnSync(bash, ['--noprofile', '--norc', join(dir, 'timeout.sh').replaceAll('\\', '/')], { cwd: dir, encoding: 'utf8' })
expect(result.status, result.stderr).toBe(0)
expect(state(dir).pending[0]).toBe(before)
})
it.each([true, false])('queues the final epoch before apply; queue success=%s', (success) => {
const dir = workspace(); writeFileSync(join(dir, 'last_hash'), 'previous'); writeFileSync(join(dir, 'kernel_state'), 'nft digest')
const block = source.slice(source.indexOf('# Durably queue the final old-epoch'), source.indexOf('# Store a verified structural digest'))
writeFileSync(join(dir, 'order.sh'), `set -euo pipefail\nHASH_FILE=last_hash; KERNEL_STATE_FILE=kernel_state; BACKEND=nft;\ncollect_telemetry() { echo snapshot >> calls; }\ntelemetry_worker() { echo "$1" >> calls; ${success ? ':' : 'return 1'}; }\nflush_reports() { echo flush >> calls; }\ntype() { return 0; }\napply_nft() { echo apply >> calls; }\n${block}`)
const result = spawnSync(bash, ['--noprofile', '--norc', join(dir, 'order.sh').replaceAll('\\', '/')], { cwd: dir, encoding: 'utf8' })
const calls = readFileSync(join(dir, 'calls'), 'utf8').trim().split(/\r?\n/)
expect(calls).toEqual(success ? ['snapshot', 'enqueue', 'flush', 'apply'] : ['snapshot', 'enqueue'])
expect(result.status).toBe(success ? 0 : 1)
})
it('drains loss counts once per durable enqueue: one loss then reports 1,0,0', () => {
const dir = initialize()
const accumulated = state(dir); accumulated.lost_reports = 1
writeFileSync(join(dir, 'telemetry-state.json'), JSON.stringify(accumulated))
snapshot(dir, [rule('input', 'input/drop', 'drop', 5)])
queue(dir); queue(dir); queue(dir)
const counts = state(dir).pending.map((bytes: string) => JSON.parse(bytes).quality.lost_reports)
expect(counts).toEqual([1, 0, 0])
expect(counts.reduce((a: number, b: number) => a + b, 0)).toBe(1)
expect(state(dir).lost_reports).toBe(0)
})
it('retains accumulated loss when the durable enqueue write fails', () => {
const dir = initialize(); const accumulated = state(dir); accumulated.lost_reports = 1
writeFileSync(join(dir, 'telemetry-state.json'), JSON.stringify(accumulated))
mkdirSync(join(dir, 'telemetry-state.json.new'))
expect(worker(dir, 'enqueue', 'ok', 'nft').status).not.toBe(0)
expect(state(dir).lost_reports).toBe(1)
expect(state(dir).pending).toEqual([])
})
})
@@ -0,0 +1,213 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { renderMikrotikPolicyRsc } from './policy/mikrotik-rsc.js'
import type { EvaluatedPolicy } from './policy/evaluate.js'
const script = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const python = process.platform === 'win32' ? 'python' : 'python3'
const dirs: string[] = []
function workspace() { const dir = mkdtempSync(join(tmpdir(), 'evofw-agent-test-')); dirs.push(dir); return dir }
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
function embedded(name: string) {
const body = script.slice(script.indexOf(`${name}() {`))
const start = body.indexOf("<<'PY'") + 7
return body.slice(start, body.indexOf('\nPY', start)).trimStart()
}
const policy = (patch = {}) => ({ hash: 'sha256:' + 'a'.repeat(64), default_action: 'accept', deny_cidrs: ['10.0.0.1/24'], allow_cidrs: ['192.0.2.1'], port_rules: [], ...patch })
function normalize(value: unknown) {
const dir = workspace(); const path = join(dir, 'policy.json'); writeFileSync(path, JSON.stringify(value))
return { dir, result: spawnSync(python, ['-c', embedded('parse_policy'), path, dir], { encoding: 'utf8' }) }
}
function render(value: unknown, counters = '1', hits = '1') {
const { dir, result } = normalize(value); expect(result.status, result.stderr).toBe(0)
return spawnSync(python, ['-c', embedded('render_nft_batch'), join(dir, 'normalized.json'), counters, hits, '1'], { encoding: 'utf8' })
}
const pythonExe = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
function shell(content: string) {
content = `python3() { '${pythonExe}' \"$@\"; }\n` + content
const dir = workspace(); const path = join(dir, 'test.sh'); writeFileSync(path, content)
return { dir, result: spawnSync(bash, ['--noprofile', '--norc', path.replaceAll('\\', '/')], { cwd: dir, encoding: 'utf8' }) }
}
function fn(name: string, next: string) { return script.slice(script.indexOf(`${name === "apply_ipset" ? "prepare_ipset_rules" : name}() {`), script.indexOf(next, script.indexOf(`${name}() {`))) }
describe('agent hardening policy boundary', () => {
it('normalizes and collapses IPv4 networks through data files', () => {
const { dir, result } = normalize(policy({ deny_cidrs: ['10.0.0.1/25', '10.0.0.128/25'] }))
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(dir, 'deny'), 'utf8').replaceAll('\r', '')).toBe('10.0.0.0/24\n')
expect(readFileSync(join(dir, 'allow'), 'utf8').replaceAll('\r', '')).toBe('192.0.2.1/32\n')
})
it.each([
{ deny_cidrs: ['$(touch injected)'] },
{ allow_cidrs: ['10.0.0.1; flush ruleset'] },
{ hash: '$(touch injected)' },
{ default_action: 'accept\nflush ruleset' },
{ deny_cidrs: ['999.1.1.1/24'] },
{ deny_cidrs: '10.0.0.0/8' },
{ port_rules: [{ id: 'x', action: 'open', protocol: 'tcp', port_start: true, src_cidrs: ['0.0.0.0/0'] }] },
{ port_rules: [{ id: 'x', action: 'open', protocol: 'udp', port_start: 443, port_end: 65536, src_cidrs: [] }] },
{ port_rules: [{ id: 'x', action: 'execute', protocol: 'tcp', port_start: 1, src_cidrs: [] }] },
])('rejects malformed or unsupported input before emitting files: %j', (patch) => {
const { dir, result } = normalize(policy(patch))
expect(result.status).not.toBe(0)
expect(existsSync(join(dir, 'hash'))).toBe(false)
expect(existsSync(join(dir, 'injected'))).toBe(false)
})
it('renders one nft candidate preserving deny, close, open, implicit-close and Docker scopes', () => {
const rule = { protocol: 'tcp', port_start: 443, port_end: 443, src_cidrs: ['192.0.2.0/24'] }
const result = render(policy({ port_rules: [{ ...rule, id: 'open', action: 'open' }, { ...rule, id: 'close', action: 'close' }] }))
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.replaceAll('\r', '').startsWith('delete table inet evofw\nadd table inet evofw\n')).toBe(true)
expect(result.stdout).toContain('prerouting fib daddr type local ip saddr @port_src_0_v4 tcp dport 443')
expect(result.stdout).toContain('forward ct status dnat ip saddr @port_src_0_v4 tcp dport 443')
const input = result.stdout.split('\n').filter((line) => line.startsWith('add rule inet evofw input ')).join('\n')
expect(input.indexOf('ip saddr @deny_v4 counter drop')).toBeLessThan(input.indexOf('ip saddr @port_src_1_v4'))
expect(input.indexOf('ip saddr @port_src_1_v4')).toBeLessThan(input.indexOf('ip saddr @port_src_0_v4'))
expect(input.indexOf('ip saddr @port_src_0_v4')).toBeLessThan(input.indexOf('evofw-port-implicit'))
expect(input.indexOf('evofw-port-implicit')).toBeLessThan(input.indexOf('ip saddr @allow_v4'))
})
it('encodes untrusted rule IDs without injecting nft syntax', () => {
const result = render(policy({ port_rules: [{ id: '"; flush ruleset; # $(touch injected)', action: 'close', protocol: 'tcp', port_start: 22, src_cidrs: ['0.0.0.0/0'] }] }))
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).not.toContain('flush ruleset')
expect(result.stdout).not.toContain('touch injected')
expect(result.stdout).toMatch(/comment "evofw-port-[a-f0-9]+-(tcp|udp)-v[46]"/)
})
})
describe('agent hardening shell control flow (mock kernel)', () => {
it('has valid bash syntax for the agent and installer', () => {
for (const name of ['evofw-firewall.sh', 'install.sh']) {
const path = fileURLToPath(new URL(`../agent-scripts/${name}`, import.meta.url)).replaceAll('\\', '/')
const result = spawnSync(bash, ['--noprofile', '--norc', '-n', path], { encoding: 'utf8' })
expect(result.status, result.stderr || String(result.error)).toBe(0)
}
})
it('never mutates nft when every candidate check fails', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; LOG_FILE=log; DENY=(); ALLOW=();\nlog() { :; }\nrender_nft_batch() { echo candidate; }\nnft() { printf '%s\\n' "$*" >> calls; return 1; }\n${fn('apply_nft', '# Collect observed host firewall')}\nif apply_nft; then exit 9; fi\n`)
expect(result.status, result.stderr).toBe(0)
const calls = readFileSync(join(dir, 'calls'), 'utf8')
expect(calls.match(/--check --file/g)).toHaveLength(3)
expect(calls.split('\n').filter((line) => line.startsWith('--file'))).toHaveLength(0)
})
it('commits the exact checked candidate only once', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; LOG_FILE=log; DENY=(10.0.0.0/8); ALLOW=();\nlog() { :; }\nrender_nft_batch() { echo candidate; }\nnft() { printf '%s\\n' "$*" >> calls; return 0; }\n${fn('apply_nft', '# Collect observed host firewall')}\napply_nft\n[[ "$APPLIED" == 1 && "$KERNEL_METHOD" == nft ]]\n`)
expect(result.status, result.stderr).toBe(0)
const calls = readFileSync(join(dir, 'calls'), 'utf8').split('\n')
expect(calls.filter((line) => line.startsWith('--check --file'))).toHaveLength(1)
expect(calls.filter((line) => line.startsWith('--file'))).toHaveLength(1)
})
it('rolls the first ipset back if the second swap fails', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; PORT_RULES_COUNT=0; DEFAULT_ACTION=accept; DENY=(10.0.0.0/8); ALLOW=();\nlog() { :; }\nipset() { printf '%s\\n' "$*" >> calls; if [[ "$1" == save ]]; then echo "create $2 hash:net family inet counters"; elif [[ "$*" == 'swap evofw_allow_stage_v4 evofw_allow_v4' ]]; then return 1; fi; }\niptables() { return 1; }\niptables-save() { printf '%s\\n' '-A INPUT -j EVOFW_INPUT'; }\niptables-restore() { cat >/dev/null; }\n${fn('apply_ipset', 'collect_telemetry() {')}\nif apply_ipset; then exit 9; fi\n`)
expect(result.status, result.stderr).toBe(0)
const calls = readFileSync(join(dir, 'calls'), 'utf8')
expect(calls.match(/swap evofw_deny_stage_v4 evofw_deny_v4/g)).toHaveLength(2)
expect(calls.match(/swap evofw_allow_stage_v4 evofw_allow_v4/g)).toHaveLength(1)
})
})
describe('agent hardening drift and RouterOS staging', () => {
it('ignores ordinary nft counter churn but detects changed enforcement', () => {
const dir = workspace()
const digest = (packets: number, verdict: string, elements: string[]) => {
writeFileSync(join(dir, 'live-kernel.json'), JSON.stringify({ nftables: [
{ metainfo: { version: '1.0' } },
{ set: { family: 'inet', table: 'evofw', name: 'deny_port_hits', elem: elements } },
{ rule: { chain: 'input', handle: packets + 1, expr: [{ counter: { packets, bytes: packets * 100 } }, { [verdict]: null }] } },
] }))
const result = spawnSync(python, ['-c', embedded('kernel_digest'), 'nft', dir], { encoding: 'utf8' })
expect(result.status, result.stderr).toBe(0)
return result.stdout.trim()
}
expect(digest(1, 'drop', ['a'])).toBe(digest(200, 'drop', ['b']))
expect(digest(1, 'drop', [])).not.toBe(digest(1, 'accept', []))
})
const rscPolicy = (patch = {}): EvaluatedPolicy => ({ hash: 'sha256:' + 'a'.repeat(64), generation: 1, applyVersion: 3, defaultAction: 'accept', policyMode: 'blacklist', denyCidrs: ['10.0.0.0/8'], allowCidrs: ['192.0.2.0/24'], portRules: [], conflictsDropped: 0, syncIntervalSec: 60, chain: [], summary: { sets: 0, rulesDeny: 0, rulesAllow: 0, cidrsDeny: 1, cidrsAllow: 1, overrides: 0, conflictsDropped: 0, portRules: 0 }, ...patch })
it('stages both RouterOS address families before canonical-rule activation and retains rollback snapshots', () => {
const rsc = renderMikrotikPolicyRsc(rscPolicy())
expect(rsc).not.toContain('remove [find list=EVOFW_DENY]')
expect(rsc).not.toContain('remove [find list=EVOFW_ALLOW]')
expect(rsc.indexOf('address=192.0.2.0/24')).toBeLessThan(rsc.indexOf('/ip firewall filter set [find comment=("evofw-stage-"'))
expect(rsc).toContain('disabled=($row->"disabled") comment=($row->"comment")')
expect(rsc).toContain('/ip firewall filter remove $new4')
expect(rsc).toContain('/ipv6 firewall filter remove $new6')
expect(rsc).not.toContain('on-error={}')
})
it.each([
{ denyCidrs: ['10.0.0.1; /system reboot'] },
{ hash: 'a\n/system reboot' },
{ portRules: [{ id: 'p' }] },
])('rejects invalid or unsupported RouterOS policy: %j', (patch) => {
expect(() => renderMikrotikPolicyRsc(rscPolicy(patch))).toThrow()
})
it.each([
['evofw-deny-drop-input', 'input', 'drop'],
['evofw-deny-drop-forward', 'forward', 'drop'],
['evofw-allow-accept-forward', 'forward', 'accept'],
['evofw-default-drop-forward', 'forward', 'drop'],
])('verifies repaired action/chain and refuses tampered no-op for %s', (comment, chain, action) => {
const rsc = renderMikrotikPolicyRsc(rscPolicy())
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
expect(rsc).toContain(`filter find comment=${comment}`)
expect(rsc).toContain(`get $id chain] != "${chain}"`)
expect(rsc).toContain(`get $id action] != "${action}"`)
expect(installer).toContain(`filter find comment=${comment}`)
expect(installer).toContain(`get $rule chain] != "${chain}"`)
expect(installer).toContain(`get $rule action] != "${action}"`)
expect(installer.indexOf('owned filter chain/action drift')).toBeLessThan(installer.indexOf('$hash = $EvofwLastHash'))
})
it('verifies top-of-chain ordering before a RouterOS no-change ACK', () => {
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
expect(installer).toContain('$ordered->0')
expect(installer).toContain('$ordered->1')
expect(installer).toContain('$ordered->2')
expect(installer.indexOf('owned filter order drift')).toBeLessThan(installer.indexOf('$hash = $EvofwLastHash'))
})
it('detects a foreign INPUT ACCEPT preempting the owned ipset jump', () => {
const dir = workspace()
writeFileSync(join(dir, 'live-ipset.txt'), 'create evofw_deny_v4 hash:net family inet\ncreate evofw_allow_v4 hash:net family inet\n')
const digest = (rules: string) => {
writeFileSync(join(dir, 'live-iptables.txt'), rules)
return spawnSync(python, ['-c', embedded('kernel_digest'), 'ipset', dir], { encoding: 'utf8' })
}
expect(digest('-A INPUT -j EVOFW_INPUT\n-A INPUT -j ACCEPT\n').status).toBe(0)
const preempted = digest('-A INPUT -j ACCEPT\n-A INPUT -j EVOFW_INPUT\n')
expect(preempted.status).not.toBe(0)
expect(preempted.stderr).toContain('owned INPUT jump is missing or preempted')
})
it('repositions the owned ipset jump without deleting foreign INPUT rules', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; PORT_RULES_COUNT=0; DEFAULT_ACTION=accept; DENY=(); ALLOW=();\nlog() { :; }\nipset() { if [[ "$1" == save ]]; then echo "create $2 hash:net family inet counters"; fi; }\niptables-save() { printf '%s\\n' '-A INPUT -j ACCEPT' '-A INPUT -j EVOFW_INPUT'; }\niptables-restore() { cat >rules; }\n${fn('apply_ipset', 'collect_telemetry() {')}\napply_ipset\n`)
expect(result.status, result.stderr).toBe(0)
const rules = readFileSync(join(dir, 'rules'), 'utf8')
expect(rules).toContain('-D INPUT -j EVOFW_INPUT')
expect(rules).toContain('-I INPUT 1 -j EVOFW_INPUT')
expect(rules).not.toContain('-D INPUT -j ACCEPT')
})
it('keeps RouterOS presence out of packet metrics and retries immutable pending payloads', () => {
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
expect(installer).not.toContain('packets\\":1')
expect(installer).not.toContain('/v1/agent/heartbeat')
expect(installer).toContain('"ip_counters_presence_only"')
expect(installer).toContain('"file_commit_not_powerloss_atomic"')
expect(installer).toContain('($ack->"report_id") = ($expected->"report_id")')
expect(installer).toContain('($ack->"sequence") = ($expected->"sequence")')
expect(installer).toContain('http-data=$body output=user as-value duration=1m')
expect(installer).toContain('contents=[:serialize $telemetryState to=json')
})
it('updates the RouterOS applied revision only on a structurally verified no-change path', () => {
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
const start = installer.indexOf(':if ($hash = $EvofwLastHash && $drift = 0)')
const block = installer.slice(start, installer.indexOf('} else={', start))
expect(block).toContain(':set EvofwAppliedRevision ($p->"generation")')
expect(block).not.toContain('/ip firewall filter set')
expect(block).not.toContain('EvofwTrafficEpoch')
})
})
+217
View File
@@ -0,0 +1,217 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { applyReportBodySchema, timeseriesResponseSchema } from '@evofw/shared'
import { ingestTelemetry } from './telemetry.js'
import { backfillStatsAnalytics, buildStatsRollups, queryTimeseries, runStatsRetention } from './analytics.js'
const opened: ReturnType<typeof createMemoryDb>[] = []
const base = Date.parse('2026-01-01T00:00:00Z')
const at = (seconds: number) => new Date(base + seconds * 1000).toISOString()
afterEach(() => { for (const mem of opened.splice(0)) mem.sqlite.close() })
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
for (const id of ['a', 'b']) repos.insertAgent(mem.db, { id, name: id, tokenPrefix: id, tokenHash: id, status: 'approved' })
return mem
}
function report(db: ReturnType<typeof memory>['db'], id: string, sequence: number, seconds: number, dropped: number | undefined, accepted = dropped, extra: Record<string, unknown> = {}) {
const body = applyReportBodySchema.parse({ telemetry_version: 2, report_id: `${id}-${sequence}`, sequence, boot_id: 'boot',
observed_at: at(seconds), interval_ms: 60_000, counter_epoch: { traffic: 't' }, capabilities: ['policy_ipv4'],
quality: { partial: false, truncated: false, unsupported: [], lost_reports: 0 },
detail_counts: { ip_found: 0, ip_sent: 0, port_found: 0, port_sent: 0 },
status: 'ok', packets_dropped: dropped, packets_accepted: accepted, ...extra,
})
return ingestTelemetry(db, id, body, '127.0.0.1', at(seconds))
}
const series = (db: ReturnType<typeof memory>['db'], ids = 'a', seconds = 180) => queryTimeseries(db, { from: at(0), to: at(seconds), resolution: '1m', agent_ids: ids }, at(seconds))
describe('bounded statistics analytics', () => {
it('sums contained exact minute intervals into an exact hour and flags only proportional cross-bucket splits', () => {
const { db } = memory()
report(db, 'a', 1, 0, 0, 0)
for (let minute = 1; minute <= 60; minute++) report(db, 'a', minute + 1, minute * 60, minute, minute * 2)
const hour = queryTimeseries(db, { from: at(0), to: at(3600), resolution: '1h', agent_ids: 'a' }, at(3600)).items[0]!
expect(hour).toMatchObject({ packets_dropped: 60, packets_accepted: 120, coverage: 1, estimated: false, quality: { partial: false } })
report(db, 'b', 1, 1800, 0, 0)
report(db, 'b', 2, 5400, 60, 0)
const split = queryTimeseries(db, { from: at(0), to: at(7200), resolution: '1h', agent_ids: 'b' }, at(7200))
expect(split.items.map((point) => point.packets_dropped)).toEqual([30, 30])
expect(split.items.every((point) => point.estimated)).toBe(true)
})
it('keeps complete traffic-only reports complete and preserves actual quality reasons at full coverage', () => {
const { db } = memory()
const trafficOnly = { capabilities: ['policy_ipv4', 'traffic_packets'], detail_counts: undefined }
report(db, 'a', 1, 0, 100, 100, trafficOnly)
report(db, 'a', 2, 60, 110, 130, trafficOnly)
expect(series(db, 'a', 60).items[0]).toMatchObject({ packets_dropped: 10, packets_accepted: 30, coverage: 1, estimated: false, quality: { partial: false, unsupported: [] } })
report(db, 'a', 3, 120, 120, 160, { ...trafficOnly, quality: { partial: true, truncated: false, unsupported: ['ip_counter_sampling'], lost_reports: 0 } })
const point = series(db, 'a', 120).items[1]!
expect(point.coverage).toBe(1)
expect(point.quality.unsupported).toContain('ip_counter_sampling')
expect(point.quality.unsupported).not.toContain('incomplete_coverage')
})
it('keeps explicit unknown v2 epoch deltas null when backfilling pre013 raw samples', async () => {
const { db } = memory()
for (const [id, seconds, absolute, delta] of [['v2-1', 0, 100, null], ['v2-2', 60, 1000, null], ['v2-3', 120, 1005, 5]] as const) {
repos.insertStatsSample(db, { id, agentId: 'a', telemetryVersion: 2, packetsDropped: absolute, packetsAccepted: 0, deltaDropped: delta, deltaAccepted: delta === null ? null : 0, recordedAt: at(seconds) })
}
await backfillStatsAnalytics(db, at(120))
const response = series(db, 'a', 120)
expect(response.items.map((point) => point.packets_dropped)).toEqual([null, 5])
expect(response.items[1]!.estimated).toBe(true)
})
it('counts enqueue-interval lost reports once across multiple proportional buckets and immutable retries', () => {
const { db } = memory()
report(db, 'a', 1, 0, 100)
for (const [sequence, seconds, lost] of [[2, 180, 1], [3, 240, 0], [4, 300, 0]] as const) {
report(db, 'a', sequence, seconds, 100 + seconds, undefined, { quality: { partial: lost > 0, truncated: false, unsupported: [], lost_reports: lost } })
}
report(db, 'a', 2, 180, 280, undefined, { quality: { partial: true, truncated: false, unsupported: [], lost_reports: 1 } })
const response = series(db, 'a', 300)
expect(response.items.map((point) => point.quality.lost_reports)).toEqual([0, 0, 1, 0, 0])
expect(response.items.reduce((sum, point) => sum + point.quality.lost_reports, 0)).toBe(1)
})
it('backfills old absolute samples once as estimated before raw retention deletes them', async () => {
const { db } = memory()
repos.insertStatsSample(db, { id: 'old-1', agentId: 'a', packetsDropped: 100, packetsAccepted: 0, recordedAt: at(0) })
repos.insertStatsSample(db, { id: 'old-2', agentId: 'a', packetsDropped: 160, packetsAccepted: 0, recordedAt: at(3600) })
expect(await backfillStatsAnalytics(db, at(2 * 86_400))).toBe(2)
expect(await backfillStatsAnalytics(db, at(2 * 86_400))).toBe(0)
const query = { from: at(0), to: at(3600), resolution: '1h', agent_ids: 'a' }
const response = queryTimeseries(db, query, at(2 * 86_400))
expect(response.items[0]!.packets_dropped).toBe(60)
expect(response.items[0]!.estimated).toBe(true)
expect(response.items[0]!.quality.partial).toBe(true)
await runStatsRetention(db, at(2 * 86_400))
expect(repos.listStatsSamples(db, 'a')).toHaveLength(0)
expect(queryTimeseries(db, query, at(2 * 86_400))).toEqual(response)
})
it('does not double-account when two yielding backfill runs overlap', async () => {
const { db } = memory()
for (let i = 0; i <= 30; i++) repos.insertStatsSample(db, { id: `old-${i}`, agentId: 'a', packetsDropped: i * 10, packetsAccepted: 0, recordedAt: at(i * 60) })
const counts = await Promise.all([backfillStatsAnalytics(db, at(1800)), backfillStatsAnalytics(db, at(1800))])
expect(counts[0]! + counts[1]!).toBe(31)
expect(series(db, 'a', 1800).items.reduce((sum, point) => sum + (point.packets_dropped ?? 0), 0)).toBe(300)
})
it('splits each agent observation interval proportionally across UTC buckets and preserves fleet gaps', () => {
const { db } = memory()
report(db, 'a', 1, 30, 100, 100)
report(db, 'a', 2, 150, 220, 160)
const a = series(db)
expect(a.items.map((p) => p.packets_dropped)).toEqual([30, 60, 30])
expect(a.items.map((p) => p.packets_accepted)).toEqual([15, 30, 15])
expect(a.items.map((p) => p.coverage)).toEqual([0.5, 1, 0.5])
expect(a.items[1]!.pps).toBe(1.5)
expect(a.items.every((p) => p.estimated)).toBe(true)
expect(timeseriesResponseSchema.safeParse(a).success).toBe(true)
const missingB = series(db, 'a,b')
expect(missingB.items.every((p) => p.packets_dropped === null && p.pps === null)).toBe(true)
expect(missingB.items[1]!.coverage).toBe(0.5)
report(db, 'b', 1, 0, 0, 0)
report(db, 'b', 2, 180, 180, 0)
const fleet = series(db, 'a,b')
expect(fleet.items.map((p) => p.packets_dropped)).toEqual([90, 120, 90])
expect(fleet.items.map((p) => p.coverage)).toEqual([0.75, 1, 0.75])
report(db, 'a', 2, 150, 220, 160)
expect(series(db, 'a,b')).toEqual(fleet)
})
it('tracks per-metric baseline times across missing values and skipped sequence intervals', () => {
const { db } = memory()
report(db, 'a', 1, 0, 100)
report(db, 'a', 2, 60, undefined, 110)
expect(series(db, 'a', 60).items[0]!.packets_dropped).toBeNull()
report(db, 'a', 3, 120, 120, 120)
const sample = repos.listStatsSamples(db, 'a').find((row) => row.sequence === 3)!
expect(sample.droppedIntervalStart).toBe(at(0))
expect(sample.acceptedIntervalStart).toBe(at(60))
expect(sample.intervalMs).toBe(120_000)
expect(series(db, 'a', 120).items.map((p) => p.packets_dropped)).toEqual([10, 10])
const mem = memory()
report(mem.db, 'a', 1, 0, 100)
report(mem.db, 'a', 4, 180, 130)
expect(series(mem.db).items.map((p) => p.packets_dropped)).toEqual([10, 10, 10])
expect(series(mem.db).items[2]!.quality.lost_reports).toBe(2)
})
it('leaves initial/new epoch/decreased counter gaps null and represents known zero as zero', () => {
const { db } = memory()
report(db, 'a', 1, 0, 100)
report(db, 'a', 2, 60, 105)
report(db, 'a', 3, 120, 1000, 1000, { counter_epoch: { traffic: 'new' } })
report(db, 'a', 4, 180, 1000, 1000, { counter_epoch: { traffic: 'new' } })
expect(series(db).items.map((p) => p.packets_dropped)).toEqual([5, null, 0])
expect(series(db).items[2]!.coverage).toBe(1)
expect(series(db).items[2]!.quality.partial).toBe(false)
})
it('rolls back rollups, counter state and sample receipt together on an analytics write error', () => {
const { db, sqlite } = memory()
report(db, 'a', 1, 0, 100)
sqlite.exec("CREATE TRIGGER fail_rollup BEFORE INSERT ON agent_stats_rollups BEGIN SELECT RAISE(ABORT, 'rollup failed'); END;")
expect(() => report(db, 'a', 2, 60, 105)).toThrow('rollup failed')
expect(repos.getTelemetryState(db, 'a')!.sequence).toBe(1)
expect(repos.listStatsSamples(db, 'a')).toHaveLength(1)
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(0)
sqlite.exec('DROP TRIGGER fail_rollup')
report(db, 'a', 2, 60, 105)
expect(series(db, 'a', 60).items[0]!.packets_dropped).toBe(5)
})
it('bounds enormous observation spans by retention without compressing their full delta into the retained tail', () => {
const quality = { partial: false, truncated: false, unsupported: [], lost_reports: 0 }
const now = at(12 * 3600), start = '0001-01-01T00:00:00.000Z'
const rows = buildStatsRollups('a', { start, end: now, delta: 100_000 }, { start, end: now, delta: 100_000 }, quality, now, false)
expect(rows.length).toBeLessThan(2300)
expect(rows.filter((r) => r.resolution === '1d').reduce((sum, r) => sum + (r.packetsDropped ?? 0), 0)).toBeLessThan(20)
expect(rows.every((r) => Number.isFinite(r.packetsDropped))).toBe(true)
const future = buildStatsRollups('a', { start: at(0), end: at(5000), delta: 100 }, { start: null, end: at(5000), delta: null }, quality, at(60), false)
expect(future.every((row) => row.droppedCoverageMs === 0)).toBe(true)
})
it('keeps hourly/daily data after raw deletion, expires every tier and yields between cleanup batches', async () => {
const { db, sqlite } = memory()
report(db, 'a', 1, 0, 100)
report(db, 'a', 2, 7200, 220)
repos.upsertIpBlockStats(db, 'a', [{ ip: '8.8.8.8', packets: 10 }], at(0))
let yielded = false
setImmediate(() => { yielded = true })
await runStatsRetention(db, at(2 * 86_400), 1)
expect(yielded).toBe(true)
expect(repos.listStatsSamples(db, 'a')).toHaveLength(0)
const hours = queryTimeseries(db, { from: at(0), to: at(7200), resolution: '1h', agent_ids: 'a' }, at(2 * 86_400))
expect(hours.items.map((point) => point.packets_dropped)).toEqual([60, 60])
repos.resetDisplayedStats(db, 'a')
expect(queryTimeseries(db, { from: at(0), to: at(7200), resolution: '1h', agent_ids: 'a' }, at(2 * 86_400))).toEqual(hours)
await runStatsRetention(db, at(31 * 86_400))
expect(sqlite.prepare("SELECT count(*) AS n FROM agent_stats_rollups WHERE resolution='1h'").get()).toEqual({ n: 0 })
expect(repos.listIpBlockStats(db, 'a')).toEqual([])
expect(sqlite.prepare("SELECT count(*) AS n FROM agent_stats_rollups WHERE resolution='1d'").get()).not.toEqual({ n: 0 })
await runStatsRetention(db, at(91 * 86_400))
expect(sqlite.prepare('SELECT count(*) AS n FROM agent_stats_rollups').get()).toEqual({ n: 0 })
expect(repos.getTelemetryState(db, 'a')!.sequence).toBe(2)
})
it('validates max1440 after UTC rounding, unknown agents and malformed resolutions', () => {
const { db } = memory()
expect(queryTimeseries(db, { from: at(0), to: at(1440 * 60), resolution: '1m', agent_ids: 'a' }, at(1440 * 60)).items).toHaveLength(1440)
expect(() => queryTimeseries(db, { from: at(1), to: at(1440 * 60 + 1), resolution: '1m' }, at(1440 * 60 + 1))).toThrow(/1440/)
expect(() => queryTimeseries(db, { resolution: 'toString' })).toThrow(/resolution/)
expect(() => queryTimeseries(db, { agent_ids: 'unknown' })).toThrow(/Agent not found/)
})
it('enforces per-agent IP/port cardinality budgets deterministically', () => {
const { db, sqlite } = memory()
repos.upsertIpBlockStats(db, 'a', Array.from({ length: 20 }, (_, i) => ({ ip: `192.0.2.${i}`, packets: i + 1 })), at(0))
repos.upsertPortBlockStats(db, 'a', Array.from({ length: 20 }, (_, i) => ({ ip: `192.0.2.${i}`, port: 22, protocol: 'tcp', packets: i + 1 })), at(0))
repos.enforceDetailBudget(db, 'a', 10, 5)
expect(sqlite.prepare("SELECT count(*) AS n FROM agent_ip_block_stats WHERE agent_id='a'").get()).toEqual({ n: 10 })
expect(sqlite.prepare("SELECT count(*) AS n FROM agent_port_block_stats WHERE agent_id='a'").get()).toEqual({ n: 5 })
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(20)
})
})
+144
View File
@@ -0,0 +1,144 @@
import { repos, type Db, type StatsRollupInsert } from '@evofw/db'
import { type StatsResolution, type TelemetryQuality, type TimeseriesResponse } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
export const RESOLUTION_MS: Record<StatsResolution, number> = { '1m': 60_000, '1h': 3_600_000, '1d': 86_400_000 }
export const RETENTION_MS: Record<StatsResolution, number> = { '1m': 86_400_000, '1h': 30 * 86_400_000, '1d': 90 * 86_400_000 }
const floor = (time: number, step: number) => Math.floor(time / step) * step
const iso = (time: number) => new Date(time).toISOString()
type MetricInterval = { start: string | null; end: string; delta: number | null }
/** Bounded by retention windows, even for a huge offline interval. */
export function buildStatsRollups(agentId: string, dropped: MetricInterval, accepted: MetricInterval, quality: TelemetryQuality, now: string, legacy: boolean): StatsRollupInsert[] {
const nowMs = Date.parse(now)
const rows = new Map<string, StatsRollupInsert>()
for (const resolution of ['1m', '1h', '1d'] as const) {
const step = RESOLUTION_MS[resolution]
const earliest = floor(nowMs - RETENTION_MS[resolution], step)
const latest = floor(nowMs, step)
const row = (bucket: number) => {
const key = `${resolution}:${bucket}`
let item = rows.get(key)
if (!item) {
item = { agentId, resolution, bucketStart: iso(bucket), packetsDropped: 0, packetsAccepted: 0,
droppedCoverageMs: 0, acceptedCoverageMs: 0, partial: quality.partial ? 1 : 0, truncated: quality.truncated ? 1 : 0, estimated: legacy ? 1 : 0,
unsupportedJson: JSON.stringify(quality.unsupported.slice(0, 8)), lostReports: 0 }
rows.set(key, item)
}
return item
}
for (const [metric, interval] of [['dropped', dropped], ['accepted', accepted]] as const) {
const start = interval.start === null ? NaN : Date.parse(interval.start)
const end = Date.parse(interval.end)
const duration = end - start
if (!Number.isFinite(start) || !Number.isFinite(end) || duration <= 0 || interval.delta === null || end > nowMs + 5 * 60_000) continue
// Denominator retains the entire observation interval; clipping old buckets
// must never compress a long outage's traffic into the retained tail.
const first = Math.max(earliest, floor(start, step))
const last = Math.min(latest, floor(end - 1, step))
for (let bucket = first; bucket <= last; bucket += step) {
const overlap = Math.max(0, Math.min(end, bucket + step) - Math.max(start, bucket))
if (!overlap) continue
const item = row(bucket)
const value = interval.delta * (overlap / duration)
if (metric === 'dropped') { item.packetsDropped = value; item.droppedCoverageMs = overlap }
else { item.packetsAccepted = value; item.acceptedCoverageMs = overlap }
if (overlap < duration) item.estimated = 1
}
}
// Preserve quality and unknown-baseline markers without fabricating a zero.
const observed = Math.min(nowMs, Math.max(Date.parse(dropped.end), Date.parse(accepted.end)))
if (Number.isFinite(observed) && observed >= earliest) {
const metadataBucket = Math.min(latest, floor(observed - 1, step))
if (metadataBucket >= earliest) row(metadataBucket).lostReports = quality.lost_reports
}
}
return [...rows.values()]
}
export type TimeseriesQuery = { from?: string; to?: string; resolution?: string; agent_ids?: string }
export function queryTimeseries(db: Db, query: TimeseriesQuery, now = new Date().toISOString()): TimeseriesResponse {
const resolution = query.resolution ?? '1h'
if (!Object.hasOwn(RESOLUTION_MS, resolution)) throw new AppError('VALIDATION_ERROR', 'resolution must be 1m, 1h or 1d', 400)
const selectedResolution = resolution as StatsResolution
const step = RESOLUTION_MS[selectedResolution]
const endMs = query.to ? Date.parse(query.to) : Date.parse(now)
const startMs = query.from ? Date.parse(query.from) : endMs - 86_400_000
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs >= endMs || endMs > Date.parse(now) + 5 * 60_000) throw new AppError('VALIDATION_ERROR', 'Invalid from/to range', 400)
const from = floor(startMs, step), to = Math.ceil(endMs / step) * step
if ((to - from) / step > 1440) throw new AppError('VALIDATION_ERROR', 'At most 1440 UTC buckets; use a coarser resolution', 400)
const allAgents = repos.listAgents(db)
const ids = query.agent_ids ? [...new Set(query.agent_ids.split(',').map((id) => id.trim()).filter(Boolean))] : allAgents.filter((agent) => agent.status === 'approved').map((agent) => agent.id)
if (ids.length > 100 || ids.some((id) => id.length > 128)) throw new AppError('VALIDATION_ERROR', 'At most 100 agent_ids', 400)
if (ids.some((id) => !allAgents.some((agent) => agent.id === id))) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const rows = new Map(repos.listStatsRollups(db, ids, selectedResolution, iso(from), iso(to)).map((row) => [row.bucketStart, row]))
const items: TimeseriesResponse['items'] = []
for (let bucket = from; bucket < to; bucket += step) {
const timestamp = iso(bucket), row = rows.get(timestamp)
const dropped = row && ids.length && row.droppedAgents === ids.length && row.packetsDropped <= Number.MAX_SAFE_INTEGER ? row.packetsDropped : null
const accepted = row && ids.length && row.acceptedAgents === ids.length && row.packetsAccepted <= Number.MAX_SAFE_INTEGER ? row.packetsAccepted : null
const coverage = row && ids.length ? Math.min(1, Math.min(row.droppedCoverageMs, row.acceptedCoverageMs) / (step * ids.length)) : 0
const partial = !row || !!row.partial || coverage < 1 || dropped === null || accepted === null
const unsupported = row?.unsupportedJson ? JSON.parse(row.unsupportedJson) as string[] : []
if (coverage < 1 || dropped === null || accepted === null) unsupported.push('incomplete_coverage')
else if (partial && unsupported.length === 0) unsupported.push(row?.lostReports ? 'lost_reports' : 'partial_report')
items.push({ timestamp, packets_dropped: dropped, packets_accepted: accepted,
pps: dropped === null || accepted === null ? null : (dropped + accepted) / (step / 1000),
coverage, estimated: !!row?.estimated,
quality: { partial, truncated: !!row?.truncated, unsupported: [...new Set(unsupported)], lost_reports: Math.min(Number.MAX_SAFE_INTEGER, row?.lostReports ?? 0) },
})
}
return { from: iso(from), to: iso(to), resolution: selectedResolution, items, agent_ids: ids }
}
/** Old samples become explicitly estimated once, before raw history is removed. */
export async function backfillStatsAnalytics(db: Db, now = new Date().toISOString(), signal?: AbortSignal) {
let processed = 0
for (;;) {
if (signal?.aborted) return processed
const pending = repos.listPendingAnalytics(db, 100)
if (!pending.length) return processed
for (const sample of pending) {
if (signal?.aborted) return processed
const claimed = db.transaction((tx) => {
if (!repos.markAnalyticsProcessed(tx, sample.id)) return false
const previous = repos.previousStatsSample(tx, sample)
const delta = (current: number, before: number | undefined, present: number | null) => present === 0 || before === undefined ? null : current >= before ? current - before : current
// An explicit v2 null is an unknown epoch/baseline interval, never an
// invitation to infer a delta from unrelated absolute counters.
const dropped = sample.droppedPresent === 0 ? null : sample.telemetryVersion === 2 ? sample.deltaDropped : sample.deltaDropped ?? delta(sample.packetsDropped, previous?.packetsDropped, sample.droppedPresent)
const accepted = sample.acceptedPresent === 0 ? null : sample.telemetryVersion === 2 ? sample.deltaAccepted : sample.deltaAccepted ?? delta(sample.packetsAccepted, previous?.packetsAccepted, sample.acceptedPresent)
const rows = buildStatsRollups(sample.agentId,
{ start: sample.droppedIntervalStart ?? previous?.recordedAt ?? null, end: sample.recordedAt, delta: dropped },
{ start: sample.acceptedIntervalStart ?? previous?.recordedAt ?? null, end: sample.recordedAt, delta: accepted },
{ partial: true, truncated: false, unsupported: ['legacy_estimated_backfill'], lost_reports: 0 }, now, true)
repos.addStatsRollups(tx, rows)
return true
})
if (claimed && ++processed % 10 === 0) await new Promise<void>((resolve) => setImmediate(resolve))
}
}
}
export async function runStatsRetention(db: Db, now = new Date().toISOString(), batchSize = 1000, signal?: AbortSignal) {
if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 5000 || !Number.isFinite(Date.parse(now))) throw new Error('Invalid retention parameters')
let deleted = 0
await backfillStatsAnalytics(db, now, signal)
const nowMs = Date.parse(now)
const cutoffs = [
['raw', iso(nowMs - 86_400_000)], ['ip', iso(nowMs - 7 * 86_400_000)], ['port', iso(nowMs - 7 * 86_400_000)],
...(['1m', '1h', '1d'] as const).map((resolution) => [resolution, iso(floor(nowMs - RETENTION_MS[resolution], RESOLUTION_MS[resolution]))]),
] as const
for (const [kind, cutoff] of cutoffs) {
for (;;) {
if (signal?.aborted) return deleted
const count = repos.deleteAnalyticsBatch(db, kind as 'raw' | 'ip' | 'port' | StatsResolution, cutoff, batchSize)
deleted += count
if (count < batchSize) break
await new Promise<void>((resolve) => setImmediate(resolve))
}
}
return deleted
}
+99
View File
@@ -0,0 +1,99 @@
import type { Sqlite } from '@evofw/db'
import { ingestAuditEventSchema, type IngestAuditEvent } from '@evofw/shared'
import type { AppConfig } from '../config.js'
export const AUDIT_QUEUE_LIMIT = 10_000
export const AUDIT_PAYLOAD_BYTES = 16_384
export const AUDIT_QUARANTINE_LIMIT = 100
const QUARANTINE_AT = Number.MAX_SAFE_INTEGER
interface Row { event_id: string; payload_json: string; attempts: number }
/** Repair known legacy field limits, then enforce the actual portal contract. */
export function normalizePortalAudit(value: unknown): IngestAuditEvent {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Audit payload contract invalid')
const event = { ...(value as Record<string, unknown>) }
if (typeof event.summary === 'string') event.summary = event.summary.trim().slice(0, 500) || 'Audit event'
if (typeof event.action === 'string') event.action = event.action.trim().slice(0, 200)
if (event.actor_email !== undefined && !ingestAuditEventSchema.shape.actor_email.safeParse(event.actor_email).success) event.actor_email = null
if (event.action === 'settings.update') {
const keys = (event.details as Record<string, unknown> | null)?.changed_keys
event.details = { changed_keys: Array.isArray(keys) ? keys.filter((key): key is string => typeof key === 'string').slice(0, 100) : [] }
}
const parsed = ingestAuditEventSchema.safeParse(event)
if (!parsed.success) throw new Error('Audit payload contract invalid')
return parsed.data
}
export class AuditOutbox {
private running: Promise<void> | null = null
private abort = new AbortController()
enqueueFailures = 0
constructor(private sqlite: Sqlite, private config: AppConfig) {}
get enabled() { return !!(this.config.authAuditIngestSecret && this.config.authPortalUrl) }
size(): number { return (this.sqlite.prepare('SELECT count(*) AS n FROM audit_outbox').get() as { n: number }).n }
enqueue(event: IngestAuditEvent): void {
if (!this.enabled) return
event = normalizePortalAudit(event)
const payload = JSON.stringify(event)
if (Buffer.byteLength(payload) > AUDIT_PAYLOAD_BYTES) throw new Error('Audit payload byte limit exceeded')
if (this.size() >= AUDIT_QUEUE_LIMIT) throw new Error('Audit outbox capacity reached')
this.sqlite.prepare('INSERT INTO audit_outbox(event_id,payload_json,created_at) VALUES (?,?,?)').run(event.event_id, payload, event.created_at)
}
stats() {
const row = this.sqlite.prepare(`SELECT count(*) FILTER(WHERE next_attempt_ms != ?) AS pending,
count(*) FILTER(WHERE next_attempt_ms = ?) AS quarantined,
min(created_at) FILTER(WHERE next_attempt_ms != ?) AS oldest_at,
max(attempts) FILTER(WHERE next_attempt_ms != ?) AS max_attempts FROM audit_outbox`).get(QUARANTINE_AT, QUARANTINE_AT, QUARANTINE_AT, QUARANTINE_AT) as { pending: number; quarantined: number; oldest_at: string | null; max_attempts: number | null }
return { ...row, capacity: AUDIT_QUEUE_LIMIT, quarantine_capacity: AUDIT_QUARANTINE_LIMIT, quarantine_recovery: 'Inspect retained local audit events; invalid delivery payloads require operator repair', enqueue_failures: this.enqueueFailures, running: !!this.running, enabled: this.enabled }
}
flush(now = Date.now()): Promise<void> {
if (this.running) return this.running
if (this.abort.signal.aborted || !this.enabled) return Promise.resolve()
this.running = this.send(now).finally(() => { this.running = null })
return this.running
}
private async send(now: number): Promise<void> {
const selected = this.sqlite.prepare('SELECT event_id,payload_json,attempts FROM audit_outbox WHERE next_attempt_ms <= ? ORDER BY next_attempt_ms,created_at LIMIT 25').all(now) as Row[]
const rows: Row[] = []
const events: IngestAuditEvent[] = []
this.sqlite.transaction(() => {
for (const row of selected) {
try {
const event = normalizePortalAudit(JSON.parse(row.payload_json))
const payload = JSON.stringify(event)
if (event.event_id !== row.event_id || Buffer.byteLength(payload) > AUDIT_PAYLOAD_BYTES) throw new Error('Invalid stored payload')
this.sqlite.prepare('UPDATE audit_outbox SET payload_json=? WHERE event_id=?').run(payload, row.event_id)
rows.push(row); events.push(event)
} catch {
this.sqlite.prepare('UPDATE audit_outbox SET next_attempt_ms=?,last_error=? WHERE event_id=?').run(QUARANTINE_AT, 'invalid_portal_contract', row.event_id)
}
}
// Bounded dead letters; original local audit records are never deleted.
this.sqlite.prepare('DELETE FROM audit_outbox WHERE event_id IN (SELECT event_id FROM audit_outbox WHERE next_attempt_ms=? ORDER BY created_at DESC,event_id DESC LIMIT -1 OFFSET ?)').run(QUARANTINE_AT, AUDIT_QUARANTINE_LIMIT)
})()
if (!rows.length) return
let failure: string | null = null
try {
const response = await fetch(`${this.config.authPortalUrl}/api/v1/ingest/audit`, {
method: 'POST', redirect: 'error',
headers: { Authorization: `Bearer ${this.config.authAuditIngestSecret}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ events }),
signal: AbortSignal.any([this.abort.signal, AbortSignal.timeout(8000)]),
})
await response.body?.cancel()
if (!response.ok) failure = `HTTP ${response.status}`
} catch { failure = this.abort.signal.aborted ? 'shutdown' : 'request failed or timed out' }
if (this.abort.signal.aborted) return // Durable rows remain due on next startup.
this.sqlite.transaction(() => {
for (const row of rows) {
if (!failure) this.sqlite.prepare('DELETE FROM audit_outbox WHERE event_id=?').run(row.event_id)
else {
const attempts = Math.min(row.attempts + 1, 1_000_000)
const delay = Math.min(3_600_000, 5000 * 2 ** Math.min(attempts - 1, 10))
this.sqlite.prepare('UPDATE audit_outbox SET attempts=?,next_attempt_ms=?,last_error=? WHERE event_id=?').run(attempts, now + delay, failure, row.event_id)
}
}
})()
}
async close(): Promise<void> { this.abort.abort(); await this.running }
}
+5 -1
View File
@@ -35,7 +35,7 @@ describe('audit API', () => {
await app.close()
})
it('recordAudit pushes to portal when configured', async () => {
it('recordAudit durably queues and pushes to portal when the worker flushes', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => '',
@@ -60,7 +60,11 @@ describe('audit API', () => {
})
expect(create.statusCode).toBe(200)
expect(fetchMock).not.toHaveBeenCalled()
expect(app.auditOutbox.size()).toBe(1)
await app.auditOutbox.flush()
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(app.auditOutbox.size()).toBe(0)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://portal.test/api/v1/ingest/audit')
expect((init.headers as Record<string, string>).Authorization).toBe(
+28 -51
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import type { FastifyBaseLogger, FastifyInstance, FastifyRequest } from 'fastify'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { appendAudit, type AppendAuditInput } from '@evofw/db'
import type { AuditSeverity, AuditTargetType, IngestAuditEvent } from '@evofw/shared'
import type { AppConfig } from '../config.js'
@@ -17,11 +17,15 @@ export type RecordAuditInput = {
ip?: string | null
}
function auditDetails(value: unknown, depth = 0): unknown {
if (depth > 5) return '[depth limit]'
if (typeof value === 'string') return value.slice(0, 1024)
if (Array.isArray(value)) return value.slice(0, 100).map(item => auditDetails(item, depth + 1))
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, item]) => [key, /token|secret|password|authorization|cookie|api_key|enroll_seed/i.test(key) ? '[redacted]' : auditDetails(item, depth + 1)]))
return value
}
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
return request.ip ?? null
}
@@ -46,53 +50,22 @@ export function actorFromRequest(
}
}
async function pushAuditToPortal(
config: AppConfig,
log: FastifyBaseLogger,
event: IngestAuditEvent,
): Promise<void> {
const secret = config.authAuditIngestSecret
const portalUrl = config.authPortalUrl
if (!secret || !portalUrl) return
const url = `${portalUrl}/api/v1/ingest/audit`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8_000)
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${secret}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ events: [event] }),
signal: controller.signal,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
log.warn(
{ status: res.status, body: body.slice(0, 200), event_id: event.event_id },
'audit portal push failed',
)
}
} catch (err) {
log.warn({ err, event_id: event.event_id }, 'audit portal push error')
} finally {
clearTimeout(timeout)
}
}
/**
* Dual-write audit: local SQLite + auth-portal ingest (source_app fw).
* Portal push is fire-and-forget; local write is synchronous best-effort.
* Local event + bounded durable outbox are persisted together; one worker retries.
*/
export function recordAudit(
app: FastifyInstance,
config: AppConfig,
input: RecordAuditInput,
): void {
const sanitized = auditDetails(input.details) as Record<string, unknown> | null
const details = sanitized && Buffer.byteLength(JSON.stringify(sanitized)) <= 8192 ? sanitized : sanitized ? { truncated: true } : null
input = {
...input, details, summary: input.summary.trim().slice(0, 500) || 'Audit event',
actorUserId: input.actorUserId?.slice(0, 256), actorEmail: input.actorEmail?.slice(0, 320),
actorName: input.actorName?.slice(0, 256), targetId: input.targetId?.slice(0, 512),
}
const eventId = randomUUID()
const createdAt = new Date().toISOString()
const localInput: AppendAuditInput = {
@@ -111,12 +84,6 @@ export function recordAudit(
createdAt,
}
try {
appendAudit(app.db, localInput)
} catch (err) {
app.log.warn({ err, action: input.action }, 'audit_log local append failed')
}
const portalEvent: IngestAuditEvent = {
event_id: eventId,
source_app: 'fw',
@@ -133,7 +100,17 @@ export function recordAudit(
created_at: createdAt,
}
void pushAuditToPortal(config, app.log, portalEvent)
try {
app.sqlite.transaction(() => {
appendAudit(app.db, localInput)
app.auditOutbox.enqueue(portalEvent)
})()
} catch {
app.auditOutbox.enqueueFailures++
// Retain the local record even if queue capacity or disk delivery fails.
try { appendAudit(app.db, localInput) } catch { /* counted and logged below */ }
app.log.error({ action: input.action }, 'audit persistence or delivery enqueue failed')
}
}
export function auditMutation(
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync, copyFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { refreshIpList } from './lists/refresh.js'
import { addListEntries, rebuildListCascade } from './lists/entries.js'
import * as hostname from './policy/resolve-hostname.js'
import { evaluateAgentPolicy } from './policy/evaluate.js'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
import { encryptSecret } from './secret-cipher.js'
import * as safeFetch from './safe-fetch.js'
const temps: string[] = []
const dbs: ReturnType<typeof createMemoryDb>[] = []
// Keep these source/cascade regressions independent of the real socket transport.
// source-hardening.test.ts separately exercises its DNS and HTTP boundaries.
beforeEach(() => {
vi.spyOn(safeFetch, 'safeFetchJson').mockImplementation(async (url, options) => {
const response = await fetch(url.toString(), options)
if (!response.ok) throw new Error(`Source HTTP ${response.status}`)
return { data: await response.json(), bytes: 1 }
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
for (const mem of dbs.splice(0)) mem.sqlite.close()
for (const dir of temps.splice(0)) rmSync(dir, { recursive: true })
})
function memory() {
const mem = createMemoryDb(); dbs.push(mem); runMigrations(mem.sqlite); return mem
}
function agent(db: ReturnType<typeof memory>['db'], id: string) {
repos.insertAgent(db, { id, name: id, tokenPrefix: id, tokenHash: id, status: 'approved' })
}
function list(db: ReturnType<typeof memory>['db'], id: string, type = 'json_url') {
repos.insertIpList(db, { id, name: id, type, configJson: JSON.stringify({ url: 'https://feed.test/prefixes' }) })
}
describe('backend data integrity', () => {
it.each(['static', 'json_url'])('atomically publishes a %s child and DNS-dependent ancestors after failure/recovery', async (type) => {
const { db } = memory(); agent(db, 'a'); repos.ensureSharedSetAssigned(db, 'a')
list(db, 'child', type); list(db, 'parent', 'static')
if (type === 'static') repos.updateIpList(db, 'child', { configJson: JSON.stringify({ items: [{ kind: 'ip', value: '1.1.1.1' }] }) })
repos.replaceIpListEntries(db, 'child', ['1.1.1.1'])
repos.updateIpList(db, 'parent', { configJson: JSON.stringify({ items: [{ kind: 'list', value: 'child' }, { kind: 'hostname', value: 'parent.test' }] }) })
repos.insertPolicyRule(db, { id: 'r', setId: 'set-shared-default', priority: 10, action: 'deny', listId: 'parent' })
const dns = vi.spyOn(hostname, 'resolveHostnameToCidrs').mockResolvedValue(['9.9.9.9/32'])
await rebuildListCascade(db, 'child')
const beforeChild = repos.getIpList(db, 'child')!
const beforeParent = repos.getIpList(db, 'parent')!
const generation = repos.getAgent(db, 'a')!.policyGeneration
dns.mockRejectedValueOnce(new Error('parent DNS unavailable'))
if (type === 'static') await expect(addListEntries(db, 'child', { values: ['2.2.2.2'] })).rejects.toThrow('parent DNS unavailable')
else {
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(Response.json(['2.2.2.2']))))
await refreshIpList(db, 'child')
expect(repos.getIpList(db, 'child')!.lastError).toContain('parent DNS unavailable')
}
expect(repos.getIpList(db, 'child')!.configJson).toBe(beforeChild.configJson)
expect(repos.getIpList(db, 'parent')!.configJson).toBe(beforeParent.configJson)
expect(repos.listIpListEntries(db, 'child').map((row) => row.cidr)).toEqual(['1.1.1.1/32'])
expect(repos.listIpListEntries(db, 'parent').map((row) => row.cidr).sort()).toEqual(['1.1.1.1/32', '9.9.9.9/32'])
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(generation)
if (type === 'static') await addListEntries(db, 'child', { values: ['2.2.2.2'] })
else await refreshIpList(db, 'child')
expect(repos.listIpListEntries(db, 'parent').map((row) => row.cidr)).toContain('2.2.2.2/32')
expect(repos.getAgent(db, 'a')!.policyGeneration).toBeGreaterThan(generation)
expect(repos.getIpList(db, 'child')!.lastError).toBeNull()
})
it('decrypts autocomplete credentials and validates manual config entries at creation', async () => {
vi.stubEnv('EVOFW_SECRET_KEY', 'k'.repeat(32))
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } })
try {
repos.setSetting(app.db, 'evobgp_api_url', 'https://bgp.test')
repos.setSetting(app.db, 'evobgp_api_token', encryptSecret('upstream-secret'))
const fetcher = vi.fn().mockResolvedValue(Response.json({ items: [{ id: 'one', community: '65000:1' }], has_more: false }))
vi.stubGlobal('fetch', fetcher)
const autocomplete = await app.inject({ method: 'GET', url: '/api/v1/integrations/evobgp/communities' })
expect(autocomplete.statusCode).toBe(200)
expect(fetcher.mock.calls[0]![1].headers.Authorization).toBe('Bearer upstream-secret')
expect(autocomplete.body).not.toContain('upstream-secret')
const invalid = await app.inject({ method: 'POST', url: '/api/v1/lists', payload: { name: 'invalid', type: 'static', config: { items: [{ kind: 'cidr', value: '999.1.1.1/24' }] } } })
expect(invalid.statusCode).toBe(400)
expect(repos.listIpLists(app.db)).toHaveLength(0)
const created = await app.inject({ method: 'POST', url: '/api/v1/lists', payload: { name: 'valid', type: 'static', config: { items: [{ kind: 'cidr', value: '192.0.2.199/24' }] } } })
expect(created.statusCode).toBe(200)
expect(repos.listIpListEntries(app.db, created.json().id).map((row) => row.cidr)).toEqual(['192.0.2.0/24'])
} finally { await app.close() }
})
it('rolls back failed DDL and migration stamp, then retries cleanly', () => {
const mem = createMemoryDb(); dbs.push(mem)
const dir = mkdtempSync(join(tmpdir(), 'evofw-migrations-')); temps.push(dir)
const file = join(dir, '001_test.sql')
writeFileSync(file, 'CREATE TABLE example(id TEXT PRIMARY KEY); INSERT INTO nonexistent VALUES (1);')
expect(() => runMigrations(mem.sqlite, dir)).toThrow()
expect(mem.sqlite.prepare("SELECT name FROM sqlite_master WHERE name='example'").get()).toBeUndefined()
expect(mem.sqlite.prepare('SELECT count(*) AS n FROM _migrations').get()).toEqual({ n: 0 })
expect(mem.sqlite.pragma('foreign_keys', { simple: true })).toBe(1)
writeFileSync(file, 'CREATE TABLE example(id TEXT PRIMARY KEY);')
runMigrations(mem.sqlite, dir)
expect(mem.sqlite.prepare('SELECT count(*) AS n FROM _migrations').get()).toEqual({ n: 1 })
})
it('preserves child rows through populated agents table rebuilds', () => {
const mem = createMemoryDb(); dbs.push(mem)
const dir = mkdtempSync(join(tmpdir(), 'evofw-upgrade-')); temps.push(dir)
const source = resolve('../../packages/db/migrations')
for (const name of ['001_initial.sql', '002_policy_sets.sql', '003_install_links.sql']) copyFileSync(join(source, name), join(dir, name))
runMigrations(mem.sqlite, dir)
mem.sqlite.prepare("INSERT INTO agents(id,name,token_prefix,token_hash,status) VALUES('a','a','a','a','approved')").run()
mem.sqlite.prepare("INSERT INTO agent_policy_sets(agent_id,set_id,sort) VALUES('a','set-shared-default',0)").run()
mem.sqlite.prepare("INSERT INTO ip_overrides(id,agent_id,cidr,action) VALUES('o','a','1.1.1.1/32','deny')").run()
runMigrations(mem.sqlite)
expect(repos.listSetsForAgent(mem.db, 'a')).toHaveLength(1)
expect(repos.listOverrides(mem.db, 'a')).toHaveLength(1)
expect(mem.sqlite.pragma('foreign_key_check')).toEqual([])
expect(mem.sqlite.pragma('foreign_keys', { simple: true })).toBe(1)
})
it('replaces 50k entries in bounded SQL batches and skips identical snapshots', () => {
const { db } = memory(); list(db, 'large')
const cidrs = Array.from({ length: 50_000 }, (_, i) => `10.${i >> 8}.${i & 255}.1/24`)
expect(repos.replaceIpListEntries(db, 'large', cidrs)).toBe(true)
expect(repos.listIpListEntries(db, 'large')).toHaveLength(50_000)
expect(repos.replaceIpListEntries(db, 'large', cidrs.reverse())).toBe(false)
})
it('fetches every cursor page, retains last good on a later-page failure and skips unchanged writes', async () => {
const { db } = memory(); agent(db, 'a'); list(db, 'l', 'evobgp_community')
repos.updateIpList(db, 'l', { configJson: JSON.stringify({ api_url: 'https://bgp.test', api_token: 'token', community_id: '01234567-0123-0123-0123-012345678901' }) })
repos.insertPolicyRule(db, { id: 'r', setId: 'set-shared-default', priority: 10, action: 'deny', listId: 'l' })
repos.ensureSharedSetAssigned(db, 'a')
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ prefixes: ['192.0.2.99/24'], has_more: true, next_cursor: 'page2' })).mockResolvedValueOnce(Response.json({ prefixes: ['2001:db8::1/32'], has_more: false }))
vi.stubGlobal('fetch', fetcher)
await refreshIpList(db, 'l')
expect(fetcher.mock.calls[1]![0]).toContain('cursor=page2')
expect(repos.listIpListEntries(db, 'l').map((r) => r.cidr).sort()).toEqual(['192.0.2.0/24', '2001:db8::/32'])
const generation = repos.getAgent(db, 'a')!.policyGeneration
const rowIds = repos.listIpListEntries(db, 'l').map((r) => r.id)
fetcher.mockResolvedValueOnce(Response.json({ prefixes: ['2001:db8::/32', '192.0.2.0/24'], has_more: false }))
await refreshIpList(db, 'l')
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(generation)
expect(repos.listIpListEntries(db, 'l').map((r) => r.id)).toEqual(rowIds)
fetcher.mockResolvedValueOnce(Response.json({ prefixes: ['8.8.8.8'], has_more: true, next_cursor: 'bad' })).mockRejectedValueOnce(new Error('upstream unavailable'))
await refreshIpList(db, 'l')
expect(repos.getIpList(db, 'l')!.lastError).toContain('unavailable')
expect(repos.listIpListEntries(db, 'l').map((r) => r.id)).toEqual(rowIds)
})
it('rejects malformed feed without clearing last good entries', async () => {
const { db } = memory(); list(db, 'l'); repos.replaceIpListEntries(db, 'l', ['8.8.8.8'])
for (const body of [{ error: 'bad' }, ['invalid.prefix/30'], [null]]) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(body)))
await refreshIpList(db, 'l')
expect(repos.getIpList(db, 'l')!.lastError).toBeTruthy()
expect(repos.listIpListEntries(db, 'l').map((r) => r.cidr)).toEqual(['8.8.8.8/32'])
}
})
it('prevents self clone and cross-agent override deletion and rejects duplicate reorder', () => {
const { db } = memory(); agent(db, 'a'); agent(db, 'b')
repos.insertOverride(db, { id: 'o', agentId: 'a', cidr: '8.8.8.8/32', action: 'deny' })
expect(() => repos.cloneRulesFrom(db, 'a', 'a', true)).toThrow(/itself/)
expect(repos.deleteOverride(db, 'o', 'b').changes).toBe(0)
expect(repos.listOverrides(db, 'a')).toHaveLength(1)
for (const [id, priority] of [['r1', 9000], ['r2', 9010]] as const) repos.insertPolicyRule(db, { id, setId: 'set-shared-default', priority, action: 'deny', cidr: '8.8.8.8/32' })
expect(() => repos.reorderPolicyRules(db, 'set-shared-default', ['r1', 'r1'])).toThrow(/exactly once/)
repos.reorderPolicyRules(db, 'set-shared-default', ['r2', 'r1'])
expect(repos.listPolicyRules(db, 'set-shared-default').map((r) => r.id)).toEqual(['r2', 'r1'])
})
it('hash depends on policy content and unchanged manual refresh does not bump agents', async () => {
const { db } = memory(); agent(db, 'a'); repos.ensureSharedSetAssigned(db, 'a')
list(db, 'manual', 'static')
repos.updateIpList(db, 'manual', { configJson: JSON.stringify({ items: [{ kind: 'ip', value: '8.8.8.8' }] }) })
repos.insertPolicyRule(db, { id: 'r', setId: 'set-shared-default', priority: 10, action: 'deny', listId: 'manual' })
await rebuildListCascade(db, 'manual')
const generation = repos.getAgent(db, 'a')!.policyGeneration
const hash = evaluateAgentPolicy(db, 'a').hash
await refreshIpList(db, 'manual')
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(generation)
repos.bumpAgentGeneration(db, 'a')
expect(evaluateAgentPolicy(db, 'a').hash).toBe(hash)
expect(() => repos.deleteIpList(db, 'manual')).toThrow(/referenced/)
})
})
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
const opened: ReturnType<typeof createMemoryDb>[] = []
afterEach(() => { vi.restoreAllMocks(); for (const mem of opened.splice(0)) mem.sqlite.close() })
const first = '2026-01-01T00:00:00.000Z'
const second = '2026-01-01T00:01:00.000Z'
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
for (const id of ['a', 'b']) repos.insertAgent(mem.db, { id, name: id, tokenPrefix: id, tokenHash: id, status: 'approved' })
return mem
}
function hits(count: number, packets: number) {
return Array.from({ length: count }, (_, index) => ({ ip: `10.${Math.floor(index / 256)}.${index % 256}.1`, port: index + 1, protocol: 'tcp', packets }))
}
describe('bounded detail counter batches', () => {
for (const count of [200, 500]) it(`preserves ${count} detail baselines and prepares bounded queries`, () => {
const { db, sqlite } = memory()
// Older rows sharing the same IP must not expand the port prefetch result.
repos.upsertPortBlockStats(db, 'a', Array.from({ length: 900 }, (_, index) => ({ ip: '10.0.0.1', port: index + 1000, protocol: 'udp', packets: 1 })), first)
repos.upsertIpBlockStats(db, 'b', hits(count, 999), first)
const prepare = vi.spyOn(sqlite, 'prepare')
const started = performance.now()
repos.upsertIpBlockStats(db, 'a', hits(count, 100), first, { counterEpoch: 'one' })
repos.upsertPortBlockStats(db, 'a', hits(count, 100), first, { counterEpoch: 'one' })
const calls = prepare.mock.calls.map(([query]) => String(query))
expect(calls.filter((query) => /^select /i.test(query))).toHaveLength(2 * Math.ceil(count / 200))
expect(calls.filter((query) => /^insert into /i.test(query))).toHaveLength(2)
expect(calls.filter((query) => /^select /i.test(query)).every((query) => query.includes(' in (') || query.includes('"port" = ?'))).toBe(true)
prepare.mockRestore()
const unknown = vi.fn()
repos.upsertIpBlockStats(db, 'a', hits(count, 105), second, { counterEpoch: 'one', onUnknownInterval: unknown })
repos.upsertPortBlockStats(db, 'a', hits(count, 105), second, { counterEpoch: 'one', onUnknownInterval: unknown })
expect(unknown).not.toHaveBeenCalled()
expect(repos.listIpBlockStats(db, 'a', count + 1)).toHaveLength(count)
expect(repos.listIpBlockStats(db, 'a', count + 1).every((row) => row.packets === 5 && row.firstSeenAt === first && row.lastSeenAt === second)).toBe(true)
expect(sqlite.prepare('SELECT count(*) AS n FROM agent_port_block_stats WHERE agent_id = ? AND protocol = ? AND packets = ?').get('a', 'tcp', 5)).toEqual({ n: count })
expect(repos.listIpBlockStats(db, 'b', count + 1).every((row) => row.packets === 999)).toBe(true)
repos.upsertIpBlockStats(db, 'a', hits(count, 1), second, { counterEpoch: 'two', onUnknownInterval: unknown })
repos.upsertPortBlockStats(db, 'a', hits(count, 1), second, { counterEpoch: 'two', onUnknownInterval: unknown })
expect(unknown).toHaveBeenCalledTimes(2 * count)
expect(repos.listIpBlockStats(db, 'a', count).every((row) => row.packets === 5)).toBe(true)
expect(sqlite.prepare('SELECT count(*) AS n FROM agent_port_block_stats WHERE agent_id = ? AND protocol = ? AND packets = ?').get('a', 'tcp', 5)).toEqual({ n: count })
console.info(`detail batch ${count}: baseline + delta + epoch reset, both kinds ${(performance.now() - started).toFixed(1)}ms`)
})
it('preserves sequential duplicates, legacy resets, timestamps and presence re-hits', () => {
const { db } = memory()
const ip = '192.0.2.1'
const duplicate = [10, 15, 2, 4].map((packets) => ({ ip, packets, port: 22, protocol: 'tcp' }))
repos.upsertIpBlockStats(db, 'a', duplicate, first)
repos.upsertPortBlockStats(db, 'a', duplicate, first)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(19)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(19)
repos.upsertIpBlockStats(db, 'a', [{ ip, packets: 4 }], second)
expect(repos.listIpBlockStats(db, 'a')[0]!.lastSeenAt).toBe(first)
repos.resetIpBlockStatsBaselines(db, 'a')
repos.resetPortBlockStatsBaselines(db, 'a')
repos.upsertIpBlockStats(db, 'a', [{ ip, packets: 3 }], second)
repos.upsertPortBlockStats(db, 'a', [{ ip, port: 22, protocol: 'TCP', packets: 3 }], second)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(22)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(22)
const presence = [{ ip: '192.0.2.2', packets: 50 }, { ip: '192.0.2.2', packets: 99 }]
repos.upsertIpBlockStats(db, 'a', presence, first, { mode: 'presence' })
repos.upsertIpBlockStats(db, 'a', presence, second, { mode: 'presence' })
expect(repos.listIpBlockStats(db, 'a').find((row) => row.ip === '192.0.2.2')!.packets).toBe(1)
repos.upsertIpBlockStats(db, 'a', presence, '2026-01-01T00:04:00.000Z', { mode: 'presence' })
expect(repos.listIpBlockStats(db, 'a').find((row) => row.ip === '192.0.2.2')!.packets).toBe(2)
})
it('rolls back the complete batch on overflow and preserves callback semantics', () => {
const { db, sqlite } = memory()
repos.upsertIpBlockStats(db, 'a', [{ ip: '192.0.2.2', packets: Number.MAX_SAFE_INTEGER }], first)
const batch = [{ ip: '192.0.2.1', packets: 1 }, { ip: '192.0.2.2', packets: 1 }]
expect(() => repos.upsertIpBlockStats(db, 'a', batch, second)).toThrow('safe integer')
expect(sqlite.prepare('SELECT count(*) AS n FROM agent_ip_block_stats').get()).toEqual({ n: 1 })
const overflow = vi.fn()
repos.upsertIpBlockStats(db, 'a', batch, second, { onOverflow: overflow })
expect(overflow).toHaveBeenCalledOnce()
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(Number.MAX_SAFE_INTEGER)
})
it('keeps duplicate epoch resets unknown and unchanged port timestamps stable', () => {
const { db, sqlite } = memory()
const unknown = vi.fn()
const rows = [100, 105, 1, 4].map((packets) => ({ ip: '192.0.2.1', port: 22, protocol: 'tcp', packets }))
repos.upsertIpBlockStats(db, 'a', rows, first, { counterEpoch: 'one', onUnknownInterval: unknown })
repos.upsertPortBlockStats(db, 'a', rows, first, { counterEpoch: 'one', onUnknownInterval: unknown })
expect(unknown).toHaveBeenCalledTimes(4)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(8)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(8)
repos.upsertPortBlockStats(db, 'a', [rows[3]!], second, { counterEpoch: 'one' })
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.lastSeenAt).toBe(first)
sqlite.prepare('UPDATE agent_port_block_stats SET packets = ?').run(Number.MAX_SAFE_INTEGER)
expect(() => repos.upsertPortBlockStats(db, 'a', [{ ...rows[3]!, packets: 5 }], second, { counterEpoch: 'one' })).toThrow('safe integer')
expect(sqlite.prepare('SELECT last_reported_packets AS n FROM agent_port_block_stats').get()).toEqual({ n: 4 })
})
})
+108
View File
@@ -0,0 +1,108 @@
import { afterAll, describe, expect, it } from 'vitest'
import { repos } from '@evofw/db'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
import { linkEnrollmentCredential } from './enrollment.js'
const config: AppConfig = { databaseUrl: 'sqlite::memory:', jwtSecret: 'test-secret', jwtTtlHours: 24, serverPort: 8080, staticDir: null, logLevel: 'error', authRequired: false, authIssuer: 'https://auth.test', authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'private-server-key', corsOrigins: [], authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30 }
const token = (label: string) => 'evofw_' + label.padEnd(43, 'a')
describe('one-time enrollment and recoverable credential rotation', () => {
const appPromise = buildApp({ memory: true, config })
afterAll(async () => (await appPromise).close())
async function invite(name: string, platform = 'linux') {
const app = await appPromise
const response = await app.inject({ method: 'POST', url: '/api/v1/install-links', payload: { name, platform } })
expect(response.statusCode, response.body).toBe(201)
const link = response.json()
const credential = linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, config.enrollSeed)
const body = { name, platform, token: token(name), install_link_id: link.id, client_version: platform === 'linux' ? 'linux/10' : 'rsc/10' }
const enroll = (changes = {}, presented = credential) => app.inject({ method: 'POST', url: '/v1/agent/enroll', headers: { 'x-evofw-enrollment-credential': presented }, payload: { ...body, ...changes } })
return { app, link, body, enroll, credential }
}
it('never renders a reusable global credential and consumes only successful enrollments', async () => {
const { app, link, enroll, credential } = await invite('scoped')
for (const path of ['/agent-install/' + link.id, '/' + link.slug]) {
const script = await app.inject({ url: path })
expect(script.statusCode).toBe(200)
expect(script.headers['cache-control']).toBe('no-store')
expect(script.body).toContain(credential)
expect(script.body).not.toContain(config.enrollSeed)
expect(script.body).not.toContain('export EVOFW_SEED=')
}
expect((await enroll({}, 'wrong')).statusCode).toBe(401)
expect((await enroll({ token: 'bad' })).statusCode).toBe(400)
expect(repos.getInstallLink(app.db, link.id)?.consumedAt).toBeNull()
const first = await enroll()
expect(first.statusCode, first.body).toBe(201)
expect((await enroll()).statusCode).toBe(200)
expect((await enroll({ token: token('different') })).statusCode).toBe(409)
expect(repos.getInstallLink(app.db, link.id)?.consumedAt).toBeTruthy()
const listing = await app.inject({ url: '/api/v1/install-links' })
expect(listing.body).not.toContain(credential)
expect(listing.body).not.toContain('token_hash')
})
it('serializes competing consumption and never overwrites the enrolled token', async () => {
const { app, body, link, enroll } = await invite('race')
const results = await Promise.all([enroll(), enroll({ token: token('race-other') })])
expect(results.map(x => x.statusCode).sort()).toEqual([201, 409])
const agent = repos.getAgent(app.db, link.agent_id)!
expect(agent.tokenPrefix).toBe(body.token.slice(0, 12))
})
it.each(['revoke', 'expire', 'agent-revoke'])('rejects %s even for an already downloaded script and exact consumed retry', async (kind) => {
const { app, link, enroll } = await invite('invalid-' + kind)
expect((await enroll()).statusCode).toBe(201)
if (kind === 'revoke') repos.revokeInstallLink(app.db, link.id)
if (kind === 'expire') app.sqlite.prepare('UPDATE agent_install_links SET expires_at=? WHERE id=?').run('2000-01-01T00:00:00.000Z', link.id)
if (kind === 'agent-revoke') repos.updateAgent(app.db, link.agent_id, { status: 'revoked' })
expect((await enroll()).statusCode).toBe(410)
})
it('refuses legacy server seed enrollment', async () => {
const app = await appPromise
expect((await app.inject({ method: 'POST', url: '/v1/agent/enroll', headers: { 'x-evofw-seed': config.enrollSeed }, payload: { name: 'legacy', token: token('legacy') } })).statusCode).toBeGreaterThanOrEqual(400)
})
it('gates old clients, exchanges atomically, and permits only exact bounded old-token replay', async () => {
const { app, link, body, enroll } = await invite('rotate')
expect((await enroll()).statusCode).toBe(201)
repos.updateAgent(app.db, link.agent_id, { status: 'approved', clientVersion: 'linux/8' })
const request = () => app.inject({ method: 'POST', url: `/api/v1/agents/${link.agent_id}/token-rotation` })
expect((await request()).statusCode).toBe(409)
const unsupported = await app.inject({ url: `/api/v1/agents/${link.agent_id}/token-rotation` })
expect(unsupported.json()).toMatchObject({ agent_id: link.agent_id, supported: false, client_version: 'linux/8', rotation: null })
expect(unsupported.body).not.toContain('token_hash')
expect(unsupported.body).not.toContain(body.token)
repos.updateAgent(app.db, link.agent_id, { clientVersion: 'linux/10' })
const requested = await request()
expect(requested.statusCode, requested.body).toBe(202)
const { request_id } = requested.json()
expect((await request()).json().request_id).toBe(request_id)
const oldHeaders = { authorization: 'Bearer ' + body.token }
expect((await app.inject({ url: '/v1/agent/credentials', headers: oldHeaders })).json().rotation.request_id).toBe(request_id)
const payload = { request_id, next_token: token('next') }
const exchange = (changes = {}, auth = body.token) => app.inject({ method: 'POST', url: '/v1/agent/token-rotation', headers: { authorization: 'Bearer ' + auth }, payload: { ...payload, ...changes } })
const ack = await exchange()
expect(ack.statusCode, ack.body).toBe(200)
expect((await exchange()).json()).toEqual(ack.json()) // ACK lost: persisted pending body retries unchanged.
expect((await exchange({}, payload.next_token)).statusCode).toBe(200) // Config commit succeeded, pending cleanup failed.
expect((await exchange({ next_token: token('unrelated') })).statusCode).toBe(401)
for (const path of ['/v1/agent/policy', '/v1/agent/policy.rsc', '/v1/agent/credentials']) expect((await app.inject({ url: path, headers: oldHeaders })).statusCode).toBe(401)
expect((await app.inject({ url: '/v1/agent/policy', headers: { authorization: 'Bearer ' + payload.next_token } })).statusCode).toBe(200)
expect((await request()).statusCode).toBe(409)
app.sqlite.prepare('UPDATE agent_token_rotations SET expires_at=? WHERE agent_id=?').run('2000-01-01T00:00:00.000Z', link.agent_id)
expect((await exchange()).statusCode).toBe(401)
const proof = await app.inject({ url: '/v1/agent/credentials', headers: { authorization: 'Bearer ' + payload.next_token } })
expect(proof.json()).toMatchObject({ client_id: link.agent_id, completed_rotation: { request_id } })
// A newer operator request must not erase the previous completed receipt.
expect((await request()).statusCode).toBe(202)
expect((await app.inject({ url: '/v1/agent/credentials', headers: { authorization: 'Bearer ' + payload.next_token } })).json().completed_rotation).toEqual({ request_id })
})
it('never revives a revoked agent through a completed exchange', async () => {
const { app, link, body, enroll } = await invite('revoke-exchange')
await enroll(); repos.updateAgent(app.db, link.agent_id, { status: 'approved' })
const requested = await app.inject({ method: 'POST', url: `/api/v1/agents/${link.agent_id}/token-rotation` })
const payload = { request_id: requested.json().request_id, next_token: token('revoked-next') }
const exchange = () => app.inject({ method: 'POST', url: '/v1/agent/token-rotation', headers: { authorization: 'Bearer ' + body.token }, payload })
expect((await exchange()).statusCode).toBe(200)
repos.updateAgent(app.db, link.agent_id, { status: 'revoked' })
expect((await exchange()).statusCode).toBe(401)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { createHash, createHmac, timingSafeEqual, randomUUID } from 'node:crypto'
import type { Db, Sqlite } from '@evofw/db'
import { repos } from '@evofw/db'
import type { EnrollmentCredentialBody, TokenRotationExchangeBody } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
export const CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000
export const CREDENTIAL_CLIENT_VERSIONS = new Set(['linux/10', 'rsc/10', 'install.sh/2'])
const digest = (value: string) => createHash('sha256').update(value).digest('hex')
const equal = (a: string, b: string) => timingSafeEqual(Buffer.from(digest(a)), Buffer.from(digest(b)))
type Link = NonNullable<ReturnType<typeof repos.getInstallLink>>
export function linkEnrollmentCredential(db: Db, link: Link, fallback: string): string {
const key = repos.getSetting(db, 'enroll_seed') || fallback
return createHmac('sha256', key).update(JSON.stringify(['evofw-enroll-v1', link.id, link.slug, link.agentId])).digest('base64url')
}
export function enrollWithCredential(db: Db, sqlite: Sqlite, fallback: string, body: EnrollmentCredentialBody, credential: string, ip: string) {
return sqlite.transaction(() => {
const link = repos.getInstallLink(db, body.install_link_id)
if (!link || !equal(credential, linkEnrollmentCredential(db, link, fallback))) throw new AppError('UNAUTHORIZED', 'Invalid enrollment credential', 401)
const lifecycle = sqlite.prepare('SELECT expires_at, consumed_at, consumed_token_hash FROM agent_install_links WHERE id=?').get(link.id) as { expires_at: string | null; consumed_at: string | null; consumed_token_hash: string | null }
const now = new Date().toISOString()
if (link.revokedAt || !lifecycle.expires_at || lifecycle.expires_at <= now) throw new AppError('GONE', 'Install link revoked or expired', 410)
const agent = link.agentId ? repos.getAgent(db, link.agentId) : undefined
if (!agent || agent.status === 'revoked') throw new AppError('GONE', 'Invited agent unavailable', 410)
const tokenHash = digest(body.token)
if (lifecycle.consumed_at) {
if (lifecycle.consumed_token_hash !== tokenHash || agent.tokenHash !== tokenHash) throw new AppError('CONFLICT', 'Install link already consumed', 409)
return { created: false, agent }
}
if (agent.status !== 'invited' || agent.platform !== body.platform) throw new AppError('CONFLICT', 'Invite does not match agent', 409)
const collision = repos.getAgentByTokenHash(db, tokenHash)
if (collision) throw new AppError('CONFLICT', 'Token already enrolled', 409)
const changed = sqlite.prepare('UPDATE agent_install_links SET consumed_at=?, consumed_token_hash=? WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?').run(now, tokenHash, link.id, now)
if (changed.changes !== 1) throw new AppError('CONFLICT', 'Install link already consumed', 409)
const updated = repos.updateAgent(db, agent.id, { tokenHash, tokenPrefix: body.token.slice(0, 12), status: 'pending', name: body.name, hostname: body.hostname ?? null, clientVersion: body.client_version ?? null, lastSeenAt: now, lastSeenIp: ip })!
return { created: true, agent: updated }
}).immediate()
}
interface Rotation { agent_id: string; request_id: string; expires_at: string; completed_at: string | null; previous_token_hash: string | null; next_token_hash: string | null }
export function getCredentialStatus(sqlite: Sqlite, agentId: string) {
const row = sqlite.prepare('SELECT * FROM agent_token_rotations WHERE agent_id=?').get(agentId) as Rotation | undefined
const now = new Date().toISOString()
if (row?.completed_at && row.expires_at <= now) sqlite.prepare('UPDATE agent_token_rotations SET previous_token_hash=NULL WHERE agent_id=? AND expires_at<=?').run(agentId, now)
const receipt = sqlite.prepare("SELECT r.request_id FROM agent_credential_receipts r JOIN agents a ON a.id=r.agent_id WHERE r.agent_id=? AND r.token_hash=a.token_hash AND a.status='approved'").get(agentId) as { request_id: string } | undefined
return { client_id: agentId, rotation: row && !row.completed_at && row.expires_at > now ? { request_id: row.request_id, expires_at: row.expires_at } : null, completed_rotation: receipt ? { request_id: receipt.request_id } : null }
}
export function requestTokenRotation(db: Db, sqlite: Sqlite, agentId: string) {
return sqlite.transaction(() => {
const agent = repos.getAgent(db, agentId)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
if (agent.status !== 'approved') throw new AppError('CONFLICT', 'Agent must be approved', 409)
if (!CREDENTIAL_CLIENT_VERSIONS.has(agent.clientVersion ?? '')) throw new AppError('UNSUPPORTED_CAPABILITY', 'Update agent before requesting token rotation', 409)
const active = sqlite.prepare('SELECT * FROM agent_token_rotations WHERE agent_id=?').get(agentId) as Rotation | undefined
const now = new Date().toISOString()
// Retain a completed exchange for its full retry window; do not strand a client after lost ACK.
if (active && active.expires_at > now) {
if (active.completed_at) throw new AppError('CONFLICT', 'Previous rotation recovery window still active', 409)
return { request_id: active.request_id, expires_at: active.expires_at }
}
const id = randomUUID(), expires = new Date(Date.now() + CREDENTIAL_TTL_MS).toISOString()
sqlite.prepare('INSERT INTO agent_token_rotations(agent_id,request_id,created_at,expires_at) VALUES(?,?,?,?) ON CONFLICT(agent_id) DO UPDATE SET request_id=excluded.request_id,created_at=excluded.created_at,expires_at=excluded.expires_at,completed_at=NULL,previous_token_hash=NULL,next_token_hash=NULL').run(agentId,id,now,expires)
return { request_id: id, expires_at: expires }
}).immediate()
}
export function exchangeToken(db: Db, sqlite: Sqlite, token: string, body: TokenRotationExchangeBody) {
return sqlite.transaction(() => {
const rotation = sqlite.prepare('SELECT * FROM agent_token_rotations WHERE request_id=?').get(body.request_id) as Rotation | undefined
const now = new Date().toISOString()
const agent = rotation ? repos.getAgent(db, rotation.agent_id) : undefined
if (!rotation || !agent || agent.status !== 'approved' || rotation.expires_at <= now) throw new AppError('UNAUTHORIZED', 'Invalid or expired token exchange', 401)
const current = digest(token), next = digest(body.next_token)
if (rotation.completed_at) {
if (agent.tokenHash !== rotation.next_token_hash || rotation.next_token_hash !== next || (current !== rotation.previous_token_hash && current !== rotation.next_token_hash)) throw new AppError('UNAUTHORIZED', 'Invalid token exchange retry', 401)
} else {
if (agent.tokenHash !== current) throw new AppError('UNAUTHORIZED', 'Invalid agent token', 401)
if (current === next || repos.getAgentByTokenHash(db, next)) throw new AppError('CONFLICT', 'New token must be unique', 409)
const changed = sqlite.prepare("UPDATE agents SET token_hash=?, token_prefix=? WHERE id=? AND token_hash=? AND status='approved'").run(next,body.next_token.slice(0,12),agent.id,current)
if (changed.changes !== 1) throw new AppError('CONFLICT', 'Agent credential changed', 409)
sqlite.prepare('UPDATE agent_token_rotations SET previous_token_hash=?,next_token_hash=?,completed_at=? WHERE request_id=?').run(current,next,now,body.request_id)
sqlite.prepare('INSERT INTO agent_credential_receipts(agent_id,request_id,token_hash,completed_at) VALUES(?,?,?,?) ON CONFLICT(agent_id) DO UPDATE SET request_id=excluded.request_id,token_hash=excluded.token_hash,completed_at=excluded.completed_at').run(agent.id,body.request_id,next,now)
}
return { ok: true as const, request_id: body.request_id, client_id: agent.id }
}).immediate()
}
/** Endpoint-specific identity only. Handler must still validate the exact exchange body. */
export function authenticateTokenExchange(db: Db, sqlite: Sqlite, token: string): string | null {
const hash = digest(token)
const agent = repos.getAgentByTokenHash(db, hash)
if (agent?.status === 'approved') return agent.id
const row = sqlite.prepare("SELECT r.agent_id FROM agent_token_rotations r JOIN agents a ON a.id=r.agent_id WHERE r.previous_token_hash=? AND r.completed_at IS NOT NULL AND r.expires_at>? AND a.token_hash=r.next_token_hash AND a.status='approved'").get(hash, new Date().toISOString()) as { agent_id: string } | undefined
return row?.agent_id ?? null
}
export function getOperatorCredentialStatus(db: Db, sqlite: Sqlite, agentId: string) {
const agent = repos.getAgent(db, agentId)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const status = getCredentialStatus(sqlite, agentId)
return { agent_id: agentId, supported: CREDENTIAL_CLIENT_VERSIONS.has(agent.clientVersion ?? ''), client_version: agent.clientVersion, agent_status: agent.status, rotation: status.rotation, completed_rotation: status.completed_rotation }
}
+41
View File
@@ -0,0 +1,41 @@
import { safeFetchJson, SOURCE_LIMITS } from './safe-fetch.js'
/** Fully collect bounded cursor pages. Never accept an incomplete snapshot. */
export async function fetchEvobgpPages(base: string, token: string, path: string, limit: number): Promise<Record<string, unknown>[]> {
const pages: Record<string, unknown>[] = []
const seen = new Set<string>()
let cursor: string | undefined
let elements = 0
let bytes = 0
const deadline = Date.now() + SOURCE_LIMITS.timeoutMs
for (let page = 0; page < SOURCE_LIMITS.pages; page++) {
if (Date.now() >= deadline) throw new Error('EvoBGP snapshot deadline exceeded')
if (bytes >= SOURCE_LIMITS.totalBytes) throw new Error('EvoBGP snapshot byte limit exceeded')
const url = new URL(`${base.replace(/\/$/, '')}${path}`)
url.searchParams.set('limit', String(limit))
if (cursor) url.searchParams.set('cursor', cursor)
const response = await safeFetchJson(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
timeoutMs: deadline - Date.now(),
maxBytes: Math.min(SOURCE_LIMITS.bytes, SOURCE_LIMITS.totalBytes - bytes),
})
const data = response.data
bytes += response.bytes
if (bytes > SOURCE_LIMITS.totalBytes) throw new Error('EvoBGP snapshot byte limit exceeded')
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid EvoBGP page')
const body = data as Record<string, unknown>
const items = body.prefixes ?? body.items
if (!Array.isArray(items)) throw new Error('Invalid EvoBGP page items')
elements += items.length
if (elements > SOURCE_LIMITS.elements) throw new Error('EvoBGP snapshot element limit exceeded')
if (body.has_more !== undefined && typeof body.has_more !== 'boolean') throw new Error('Invalid EvoBGP has_more')
if (body.next_cursor !== undefined && body.next_cursor !== null && (typeof body.next_cursor !== 'string' || body.next_cursor.length > 2048)) throw new Error('Invalid EvoBGP next_cursor')
pages.push(body)
if (body.has_more !== true && !body.next_cursor) return pages
const next = body.next_cursor
if (typeof next !== 'string' || !next || seen.has(next)) throw new Error('Incomplete or repeated EvoBGP cursor')
seen.add(next)
cursor = next
}
throw new Error('EvoBGP page limit exceeded')
}
+6 -4
View File
@@ -1,3 +1,5 @@
import { repos } from '@evofw/db'
import { linkEnrollmentCredential } from './enrollment.js'
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
@@ -95,7 +97,7 @@ describe('install-links', () => {
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name: 'web-02',
@@ -217,7 +219,7 @@ describe('install-links', () => {
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name: 'empty-policy',
@@ -280,7 +282,7 @@ describe('install-links', () => {
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name: 'mt-policy',
@@ -358,7 +360,7 @@ describe('install-links', () => {
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name: 'script-sha-policy',
+13 -7
View File
@@ -1,3 +1,4 @@
import { linkEnrollmentCredential } from './enrollment.js'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Db } from '@evofw/db'
@@ -90,6 +91,8 @@ export function mapInstallLink(
agent_id: row.agentId ?? null,
created_at: row.createdAt,
revoked_at: row.revokedAt,
expires_at: row.expiresAt,
consumed_at: row.consumedAt,
last_used_at: row.lastUsedAt,
use_count: row.useCount,
urls: { by_id: urls.by_id, by_slug: urls.by_slug },
@@ -124,7 +127,7 @@ function escapeRosString(s: string): string {
*/
export function renderInstallScript(opts: {
cpUrl: string
seed: string
credential: string
clientName: string
platform: string
installLinkId: string
@@ -136,7 +139,7 @@ export function renderInstallScript(opts: {
'# EvoFirewall short install link — env pre-set',
'set -euo pipefail',
`export EVOFW_CP_URL='${escape(cp)}'`,
`export EVOFW_SEED='${escape(opts.seed)}'`,
`export EVOFW_ENROLLMENT_CREDENTIAL='${escape(opts.credential)}'`,
`export EVOFW_CLIENT_NAME='${escape(opts.clientName)}'`,
`export EVOFW_PLATFORM='${escape(opts.platform)}'`,
`export EVOFW_INSTALL_LINK_ID='${escape(opts.installLinkId)}'`,
@@ -153,7 +156,7 @@ export function renderInstallScript(opts: {
*/
export function renderMikrotikInstallScript(opts: {
cpUrl: string
seed: string
credential: string
clientName: string
installLinkId: string
}): string {
@@ -161,7 +164,7 @@ export function renderMikrotikInstallScript(opts: {
const header = [
'# EvoFirewall short install link — globals pre-set (RouterOS 7.21+)',
`:global EvofwCpUrl "${escapeRosString(cp)}"`,
`:global EvofwSeed "${escapeRosString(opts.seed)}"`,
`:global EvofwEnrollmentCredential "${escapeRosString(opts.credential)}"`,
`:global EvofwName "${escapeRosString(opts.clientName)}"`,
`:global EvofwInstallLinkId "${escapeRosString(opts.installLinkId)}"`,
'',
@@ -194,13 +197,16 @@ export function resolveAndRenderInstall(
if (link.revokedAt) {
throw new AppError('GONE', 'Install link revoked', 410)
}
const seed = repos.getSetting(db, 'enroll_seed') || enrollSeedFallback
if (!link.expiresAt || link.expiresAt <= new Date().toISOString()) {
throw new AppError('GONE', 'Install link expired', 410)
}
const credential = linkEnrollmentCredential(db, link, enrollSeedFallback)
repos.touchInstallLink(db, link.id)
if (link.platform === 'mikrotik') {
return {
body: renderMikrotikInstallScript({
cpUrl: publicBaseUrl,
seed,
credential,
clientName: link.clientName,
installLinkId: link.id,
}),
@@ -210,7 +216,7 @@ export function resolveAndRenderInstall(
return {
body: renderInstallScript({
cpUrl: publicBaseUrl,
seed,
credential,
clientName: link.clientName,
platform: link.platform,
installLinkId: link.id,
+4 -3
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
import { repos } from '@evofw/db'
import { linkEnrollmentCredential } from './enrollment.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
@@ -38,7 +40,7 @@ async function enrollApprovedLinux(
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name,
@@ -274,7 +276,7 @@ describe('apply-report ip_hits / blocked-ips', () => {
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name: 'mt-hits',
@@ -348,7 +350,6 @@ describe('apply-report ip_hits / blocked-ips', () => {
expect(body2.items[0]!.last_seen_at >= firstSeen).toBe(true)
// Re-entry after stale gap (> PRESENCE_REHIT_STALE_MS): simulate via future `now`
const { repos } = await import('@evofw/db')
repos.upsertIpBlockStats(
app.db,
link.agent_id,
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { previewLabelAssignmentSchema, applyLabelAssignmentSchema, permissionForRequest } from '@evofw/shared'
import { createAgentLabel, patchAgentLabel, putAgentLabels, previewLabelAssignment, applyLabelAssignment } from './labels.js'
import { createPolicySnapshot, startPolicyRollout } from './policy/rollouts.js'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
const opened: ReturnType<typeof createMemoryDb>[] = []
afterEach(() => { for (const mem of opened.splice(0)) mem.sqlite.close() })
function memory(count = 3) {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
for (let index = 0; index < count; index++) repos.insertAgent(mem.db, { id: `a${index}`, name: `Agent ${index}`, tokenHash: `t${index}`, tokenPrefix: `t${index}`, status: 'approved', telemetryVersion: 2, capabilitiesJson: '["policy-ipv4"]' })
for (const id of ['old', 'new', 'third']) repos.insertPolicySet(mem.db, { id, name: id })
return mem
}
describe('labels and materialized group assignment', () => {
it('treats labels as metadata, uses normalized unique names and atomically validates replacement', () => {
const { db } = memory()
const label = createAgentLabel(db, { name: 'Production', color: 'success' })
expect(() => createAgentLabel(db, { name: 'PRODUCTION' })).toThrow('already exists')
putAgentLabels(db, 'a0', [label.id])
patchAgentLabel(db, label.id, { name: 'Production fleet' })
expect(repos.getAgent(db, 'a0')!.policyGeneration).toBe(1)
expect(() => putAgentLabels(db, 'a0', ['missing'])).toThrow('Label not found')
expect(repos.agentLabelIds(db, 'a0')).toEqual([label.id])
repos.deleteAgentLabel(db, label.id)
expect(repos.agentLabelIds(db, 'a0')).toEqual([])
expect(repos.getAgent(db, 'a0')!.policyGeneration).toBe(1)
})
it('preserves existing order in add, performs exact replace and skips identical assignments', () => {
const { db } = memory()
repos.setAgentPolicySets(db, 'a0', ['old', 'third'])
const request = { selector: { agent_ids: ['a0', 'a1'] }, mode: 'add' as const, set_ids: ['third', 'new'] }
const preview = previewLabelAssignment(db, request)
expect(preview.items[0]!.set_ids).toEqual(['old', 'third', 'new'])
const result = applyLabelAssignment(db, { ...request, expected: preview.items.map((row) => ({ agent_id: row.agent_id, generation: row.generation })) })
expect(result.affected_agent_ids).toEqual(['a0', 'a1'])
const again = previewLabelAssignment(db, request)
const unchanged = applyLabelAssignment(db, { ...request, expected: again.items.map((row) => ({ agent_id: row.agent_id, generation: row.generation })) })
expect(unchanged.affected_agent_ids).toEqual([])
expect(unchanged.items.map((row) => row.generation)).toEqual(again.items.map((row) => row.generation))
const replace = { selector: { agent_ids: ['a0'] }, mode: 'replace' as const, set_ids: ['new'] }
const replacement = previewLabelAssignment(db, replace)
applyLabelAssignment(db, { ...replace, expected: replacement.items.map((row) => ({ agent_id: row.agent_id, generation: row.generation })) })
expect(repos.listSetsForAgent(db, 'a0').map((row) => row.setId)).toEqual(['new'])
})
it('requires exact materialized membership and generations; later labels never autoassign', () => {
const { db } = memory()
const label = createAgentLabel(db, { name: 'Canaries' })
putAgentLabels(db, 'a0', [label.id])
const request = { selector: { label_ids: [label.id] }, mode: 'replace' as const, set_ids: ['new'] }
const preview = previewLabelAssignment(db, request)
const expected = preview.items.map((row) => ({ agent_id: row.agent_id, generation: row.generation }))
putAgentLabels(db, 'a1', [label.id])
expect(() => applyLabelAssignment(db, { ...request, expected })).toThrow('membership')
expect(repos.listSetsForAgent(db, 'a0')).toHaveLength(0)
putAgentLabels(db, 'a1', [])
applyLabelAssignment(db, { ...request, expected })
putAgentLabels(db, 'a1', [label.id])
expect(repos.listSetsForAgent(db, 'a1')).toHaveLength(0)
expect(() => applyLabelAssignment(db, { ...request, expected })).toThrow('generation')
})
it('rejects invalid sets and pinned targets before writing any agent', () => {
const { db } = memory()
const request = { selector: { agent_ids: ['a0', 'a1'] }, mode: 'replace' as const, set_ids: ['new'] }
const preview = previewLabelAssignment(db, request)
const expected = preview.items.map((row) => ({ agent_id: row.agent_id, generation: row.generation }))
expect(() => applyLabelAssignment(db, { ...request, set_ids: ['new', 'missing'], expected })).toThrow('Policy set not found')
expect(repos.listSetsForAgent(db, 'a0')).toHaveLength(0)
const snapshot = createPolicySnapshot(db, 'a2', 'Empty')
startPolicyRollout(db, { snapshot_id: snapshot.id, targets: [{ agent_id: 'a1', expected_generation: 1 }], canary_ids: ['a1'] })
expect(() => applyLabelAssignment(db, { ...request, expected })).toThrow('Release')
expect(repos.listSetsForAgent(db, 'a0')).toHaveLength(0)
expect(repos.getAgent(db, 'a0')!.policyGeneration).toBe(1)
})
it('supports exactly 100 selected agents and rejects a 101-agent label group without truncation', () => {
const { db } = memory(101)
const label = createAgentLabel(db, { name: 'Large group' })
for (let index = 0; index < 100; index++) putAgentLabels(db, `a${index}`, [label.id])
const request = { selector: { label_ids: [label.id] }, mode: 'add' as const, set_ids: ['new'] }
const preview = previewLabelAssignment(db, request)
expect(preview.total).toBe(100)
const applied = applyLabelAssignment(db, { ...request, expected: preview.items.map((row) => ({ agent_id: row.agent_id, generation: row.generation })) })
expect(applied.affected_agent_ids).toHaveLength(100)
expect(repos.listSetsForAgent(db, 'a100')).toHaveLength(0)
putAgentLabels(db, 'a100', [label.id])
expect(() => previewLabelAssignment(db, request)).toThrow('100 agents')
expect(repos.listSetsForAgent(db, 'a100')).toHaveLength(0)
expect(previewLabelAssignmentSchema.safeParse({ ...request, selector: { agent_ids: ['a0'], label_ids: [label.id] } }).success).toBe(false)
expect(applyLabelAssignmentSchema.safeParse({ ...request, expected: [{ agent_id: 'a0', generation: 1 }, { agent_id: 'a0', generation: 1 }] }).success).toBe(false)
})
it('provides HTTP label CRUD, AND fleet filtering and permission separation', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } })
try {
await app.ready()
for (const id of ['a', 'b']) repos.insertAgent(app.db, { id, name: id, tokenPrefix: id, tokenHash: id })
const one = await app.inject({ method: 'POST', url: '/api/v1/agent-labels', payload: { name: 'One' } })
const two = await app.inject({ method: 'POST', url: '/api/v1/agent-labels', payload: { name: 'Two' } })
expect(one.statusCode).toBe(201)
expect(one.json().name_key).toBeUndefined()
await app.inject({ method: 'PUT', url: '/api/v1/agents/a/labels', payload: { label_ids: [one.json().id, two.json().id] } })
await app.inject({ method: 'PUT', url: '/api/v1/agents/b/labels', payload: { label_ids: [one.json().id] } })
const filtered = await app.inject({ url: `/api/v1/agents?label_ids=${one.json().id},${two.json().id}` })
expect(filtered.json().items.map((row: { id: string }) => row.id)).toEqual(['a'])
expect((await app.inject({ url: '/api/v1/agents?label_ids=' })).json().items).toHaveLength(2)
expect(permissionForRequest('POST', '/api/v1/agent-labels/preview-assignment')).toBe('fw:policies:read')
expect(permissionForRequest('POST', '/api/v1/agent-labels/assign-policy-sets')).toBe('fw:policies:write')
expect(permissionForRequest('POST', '/api/v1/agent-labels')).toBe('fw:agents:write')
} finally { await app.close() }
})
})
+66
View File
@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto'
import { repos, type Db } from '@evofw/db'
import type { PreviewLabelAssignment, ApplyLabelAssignment, LabelAssignmentPreview } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
const fail = (code: string, message: string, status = 409): never => { throw new AppError(code, message, status) }
const requireAgent = (db: Db, id: string) => repos.getAgent(db, id) ?? fail('NOT_FOUND', 'Agent not found', 404)
const requireLabel = (db: Db, id: string) => repos.getAgentLabel(db, id) ?? fail('NOT_FOUND', 'Label not found', 404)
const nameKey = (name: string) => name.normalize('NFKC').toLocaleLowerCase('en-US')
export function createAgentLabel(db: Db, body: { name: string; color?: string | null }, now = new Date().toISOString()) {
return db.transaction((tx) => {
const key = nameKey(body.name)
if (repos.getAgentLabelByName(tx, key)) fail('LABEL_NAME_CONFLICT', 'A label with this name already exists')
if (repos.countAgentLabels(tx) >= 1000) fail('LABEL_CAPACITY', 'At most 1000 labels may be stored', 422)
const row = { id: randomUUID(), name: body.name, name_key: key, color: body.color ?? null, created_at: now }
repos.insertAgentLabel(tx, row)
return row
})
}
export function patchAgentLabel(db: Db, id: string, patch: { name?: string; color?: string | null }) {
return db.transaction((tx) => {
const previous = requireLabel(tx, id)
const name = patch.name ?? previous.name
const key = nameKey(name)
const conflict = repos.getAgentLabelByName(tx, key)
if (conflict && conflict.id !== id) fail('LABEL_NAME_CONFLICT', 'A label with this name already exists')
const row = { ...previous, name, name_key: key, color: patch.color === undefined ? previous.color : patch.color }
repos.updateAgentLabel(tx, row)
return row
})
}
export function putAgentLabels(db: Db, id: string, labelIds: string[]) {
return db.transaction((tx) => {
requireAgent(tx, id)
for (const labelId of labelIds) requireLabel(tx, labelId)
repos.replaceAgentLabels(tx, id, labelIds)
return { agent_id: id, label_ids: repos.agentLabelIds(tx, id) }
})
}
export function previewLabelAssignment(db: Db, body: PreviewLabelAssignment): LabelAssignmentPreview {
// Resolve the whole selection once. Never silently cap a larger label group.
for (const labelId of body.selector.label_ids ?? []) requireLabel(db, labelId)
const ids = body.selector.agent_ids ?? repos.agentIdsWithLabels(db, body.selector.label_ids!, 101)
if (ids.length > 100) fail('GROUP_TOO_LARGE', 'An assignment may select at most 100 agents', 422)
for (const setId of body.set_ids) if (!repos.getPolicySet(db, setId)) fail('NOT_FOUND', 'Policy set not found', 404)
const items = ids.map((id) => {
const agent = requireAgent(db, id)
repos.assertAgentPolicyMutable(db, id)
const previous = repos.listSetsForAgent(db, id).map((set) => set.setId)
const final = body.mode === 'replace' ? [...body.set_ids] : [...previous, ...body.set_ids.filter((setId) => !previous.includes(setId))]
if (final.length > 100) fail('ASSIGNMENT_TOO_LARGE', 'An agent may receive at most 100 policy sets through a group assignment', 422)
return { agent_id: agent.id, generation: agent.policyGeneration, previous_set_ids: previous, set_ids: final, changed: JSON.stringify(previous) !== JSON.stringify(final) }
})
return { mode: body.mode, items, total: items.length }
}
export function applyLabelAssignment(db: Db, body: ApplyLabelAssignment) {
return db.transaction((tx) => {
const preview = previewLabelAssignment(tx, body)
if (preview.total === 0) fail('EMPTY_SELECTION', 'No agents match this selection')
const expected = new Map(body.expected.map((row) => [row.agent_id, row.generation]))
if (expected.size !== preview.total || preview.items.some((item) => expected.get(item.agent_id) !== item.generation)) fail('REVISION_CONFLICT', 'Group membership or an agent generation changed; refresh preview')
// Every target/set/pin/precondition has been validated before the first write.
for (const item of preview.items) if (item.changed) repos.setAgentPolicySets(tx, item.agent_id, item.set_ids)
return { affected_agent_ids: preview.items.filter((item) => item.changed).map((item) => item.agent_id),
items: preview.items.map((item) => ({ agent_id: item.agent_id, generation: requireAgent(tx, item.agent_id).policyGeneration, before_count: item.previous_set_ids.length, after_count: item.set_ids.length, changed: item.changed })) }
})
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { repos } from '@evofw/db'
import { adminCredentialStatusSchema, listImportSummarySchema } from '@evofw/shared'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
describe('bounded opt-in list import', () => {
it('returns a bounded 100000-entry summary, keeps no-op generation stable and rejects mixed invalid imports atomically', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } })
try {
await app.ready()
repos.insertIpList(app.db, { id: 'large', name: 'Large', type: 'static' })
repos.insertAgent(app.db, { id: 'a', name: 'A', tokenHash: 'a', tokenPrefix: 'a', status: 'approved' })
repos.insertPolicySet(app.db, { id: 'set', name: 'Set' })
repos.insertPolicyRule(app.db, { id: 'r', setId: 'set', action: 'deny', listId: 'large', priority: 10 })
repos.setAgentPolicySets(app.db, 'a', ['set'])
const values = Array.from({ length: 100_000 }, (_, index) => `10.${Math.floor(index / 65536)}.${Math.floor(index / 256) % 256}.${index % 256}`)
const started = performance.now()
const imported = await app.inject({ method: 'POST', url: '/api/v1/lists/large/entries?return=summary', payload: { values } })
expect(imported.statusCode).toBe(200)
const summary = listImportSummarySchema.parse(imported.json())
expect(summary.count).toBe(100_000)
expect(summary.content_hash).toMatch(/^sha256:[a-f0-9]{64}$/)
expect(summary.changed).toBe(true)
expect(Buffer.byteLength(imported.body)).toBeLessThan(256)
console.info(`100000-entry import summary: ${Buffer.byteLength(imported.body)} bytes, ${(performance.now() - started).toFixed(1)}ms (Windows/in-memory)`)
const page = await app.inject({ url: '/api/v1/lists/large?view=page&limit=200&offset=99800' })
expect(page.statusCode).toBe(200)
expect(page.json().items).toHaveLength(200)
expect(page.json().total).toBe(100_000)
expect(page.json().entry_count).toBe(100_000)
expect(page.json().config_json).toBe('{}')
expect(Buffer.byteLength(page.body)).toBeLessThan(100_000)
const filters = JSON.stringify([{ field: 'value', operator: 'is', values: [values[99_999]!] }, { field: 'kind', operator: 'is', values: ['ip'] }])
const filtered = await app.inject({ url: `/api/v1/lists/large?view=page&filters=${encodeURIComponent(filters)}` })
expect(filtered.json().total).toBe(1)
expect(filtered.json().items[0]!.value).toBe(values[99_999])
const catalog = await app.inject({ url: '/api/v1/lists?view=summary' })
expect(Buffer.byteLength(catalog.body)).toBeLessThan(1000)
expect(catalog.json().items[0].config_json).toBe('{}')
console.info(`100000-entry detail page: ${Buffer.byteLength(page.body)} bytes for 200 items; filtered late match ${Buffer.byteLength(filtered.body)} bytes`)
const revision = repos.getAgent(app.db, 'a')!.policyGeneration
const duplicate = await app.inject({ method: 'POST', url: '/api/v1/lists/large/entries?return=summary', payload: { values } })
expect(duplicate.statusCode).toBe(200)
expect(duplicate.json()).toEqual({ ...summary, changed: false })
expect(repos.getAgent(app.db, 'a')!.policyGeneration).toBe(revision)
const invalid = await app.inject({ method: 'POST', url: '/api/v1/lists/large/entries?return=summary', payload: { values: ['192.0.2.100', '192.0.2.999'] } })
expect(invalid.statusCode).toBe(400)
expect(repos.getIpListEntrySummary(app.db, 'large')).toEqual({ count: 100_000, content_hash: summary.content_hash })
expect(repos.getAgent(app.db, 'a')!.policyGeneration).toBe(revision)
const legacyDuplicate = await app.inject({ method: 'POST', url: '/api/v1/lists/large/entries', payload: { values: [values[0]!] } })
expect(legacyDuplicate.statusCode).toBe(400)
const deleted = await app.inject({ method: 'DELETE', url: '/api/v1/lists/large/entries?return=summary', payload: { value: values[99_999] } })
expect(deleted.statusCode).toBe(200)
expect(deleted.json()).toMatchObject({ count: 99_999, changed: true })
expect(Buffer.byteLength(deleted.body)).toBeLessThan(256)
} finally { await app.close() }
}, 30_000)
it('preserves the default legacy detail response and rejects unsupported opt-in values', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } })
try {
await app.ready()
repos.insertIpList(app.db, { id: 'small', name: 'Small', type: 'static' })
const legacy = await app.inject({ method: 'POST', url: '/api/v1/lists/small/entries', payload: { values: ['192.0.2.1'] } })
expect(legacy.statusCode).toBe(200)
expect(legacy.json().entries).toEqual(['192.0.2.1/32'])
expect(legacy.json().items).toHaveLength(1)
const bad = await app.inject({ method: 'POST', url: '/api/v1/lists/small/entries?return=everything', payload: { values: ['192.0.2.2'] } })
expect(bad.statusCode).toBe(400)
expect(repos.getIpListEntrySummary(app.db, 'small')!.count).toBe(1)
for (const query of ['view=page&limit=0', 'view=page&limit=201', 'view=page&offset=-1', 'view=page&limit=oops', 'limit=100', 'view=page&filters=bad', 'view=page&filters=%5B%7B%22field%22%3A%22secret%22%7D%5D']) {
expect((await app.inject({ url: `/api/v1/lists/small?${query}` })).statusCode).toBe(400)
}
expect(adminCredentialStatusSchema.safeParse({ agent_id: 'a', supported: false, client_version: null, agent_status: 'pending', rotation: null, completed_rotation: null }).success).toBe(true)
} finally { await app.close() }
})
it('pages external CIDRs and bounds nested resolved previews with whole-list Unicode name filtering', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } })
try {
await app.ready()
repos.insertIpList(app.db, { id: 'child', name: 'ПРОДУКТИВНЫЙ Источник', type: 'json_url' })
const cidrs = Array.from({ length: 500 }, (_, index) => `198.51.${Math.floor(index / 256)}.${index % 256}/32`)
repos.replaceIpListEntries(app.db, 'child', cidrs)
const external = await app.inject({ url: '/api/v1/lists/child?view=page&offset=490&limit=10&q=198.51.1.' })
expect(external.json().total).toBe(244)
expect(external.json().items).toHaveLength(0)
const last = await app.inject({ url: '/api/v1/lists/child?view=page&q=198.51.1.243%2F32' })
expect(last.json().total).toBe(1)
repos.insertIpList(app.db, { id: 'parent', name: 'Parent', type: 'static' })
await app.inject({ method: 'POST', url: '/api/v1/lists/parent/entries?return=summary', payload: { items: [{ kind: 'list', value: 'child' }] } })
const filters = encodeURIComponent(JSON.stringify([{ field: 'value', operator: 'contains', values: ['продуктивный'] }]))
const parent = await app.inject({ url: `/api/v1/lists/parent?view=page&filters=${filters}` })
expect(parent.statusCode).toBe(200)
expect(parent.json().total).toBe(1)
expect(parent.json().items[0]).toMatchObject({ resolved_count: 500, resolved_truncated: true })
expect(parent.json().items[0].resolved_cidrs).toHaveLength(3)
expect(Buffer.byteLength(parent.body)).toBeLessThan(3000)
repos.insertIpList(app.db, { id: 'legacy', name: 'Legacy', type: 'domains', configJson: JSON.stringify({ domains: ['192.0.2.1', 'Example.test'] }) })
const legacy = await app.inject({ url: '/api/v1/lists/legacy?view=page' })
expect(legacy.statusCode).toBe(200)
expect(legacy.json().items.map((item: { kind: string }) => item.kind)).toEqual(['hostname', 'hostname'])
repos.insertIpList(app.db, { id: 'malformed', name: 'Malformed', type: 'static', configJson: '{' })
expect((await app.inject({ url: '/api/v1/lists/malformed?view=page' })).json().items).toEqual([])
} finally { await app.close() }
})
})
+136 -120
View File
@@ -7,12 +7,18 @@ import {
parseListEntry,
readManualItems,
splitListPlaintext,
canonicalHostname,
canonicalIp,
canonicalCidr,
type ListConfigItem,
type ListEntryInput,
type ListImportSummary,
type ListDetailPageQuery,
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
import { uniqCidrs } from '../uniq.js'
import { maskListConfig } from '../secret-cipher.js'
import { createHash } from 'node:crypto'
export function getListConfig(list: {
configJson: string
@@ -60,109 +66,103 @@ export function findParentListIds(db: Db, childId: string): string[] {
return parents
}
async function expandItem(
db: Db,
item: ListConfigItem,
visiting: Set<string>,
): Promise<string[]> {
if (item.kind === 'hostname') {
if (item.resolved_cidrs?.length) return item.resolved_cidrs
return resolveHostnameToCidrs(item.value)
type ListRevision = { configJson: string; type: string; contentHash: string | null; updatedAt: string }
type ListPublication = { cidrs?: string[]; configJson?: string; type?: string; expected?: ListRevision }
/** Resolve the whole dependency closure before publishing any config or CIDRs. */
export async function publishListCascade(db: Db, listId: string, root: ListPublication = {}): Promise<string[]> {
const snapshot = repos.listIpLists(db)
const lists = new Map(snapshot.map((list) => [list.id, list]))
const initial = lists.get(listId)
if (!initial) throw new Error('List not found')
if (root.expected && (['configJson', 'type', 'contentHash', 'updatedAt'] as const).some(key => initial[key] !== root.expected![key])) {
throw new Error('List changed during source refresh; retry refresh')
}
if (item.kind === 'list') {
const childId = item.value
if (visiting.has(childId)) {
throw new Error(`Циклическая ссылка списков: ${childId}`)
lists.set(listId, { ...initial, ...(root.configJson === undefined ? {} : { configJson: root.configJson }), ...(root.type === undefined ? {} : { type: root.type }) })
const closure = new Set([listId])
let expanded = true
while (expanded) {
expanded = false
for (const list of lists.values()) {
if (closure.has(list.id) || !isManualListType(list.type)) continue
if (listNestedChildIds(list.configJson).some((id) => closure.has(id))) {
closure.add(list.id)
expanded = true
}
}
const child = repos.getIpList(db, childId)
if (!child) {
throw new Error(`Вложенный список не найден: ${childId}`)
}
// Prefer materialized CIDRs; for manual children rebuild if empty.
let entries = repos.listIpListEntries(db, childId).map((e) => e.cidr)
if (entries.length === 0 && isManualListType(child.type)) {
entries = await rebuildManualListEntries(db, childId, visiting)
}
return entries
}
return normalizeItemToCidrs(item)
const staged = new Map<string, { cidrs: string[]; configJson: string; type: string }>()
const visiting = new Set<string>()
const stage = async (id: string): Promise<string[]> => {
if (visiting.has(id)) throw new Error(`Циклическая ссылка списков: ${id}`)
const cached = staged.get(id)
if (cached) return cached.cidrs
const list = lists.get(id)
if (!list) throw new Error(`Вложенный список не найден: ${id}`)
visiting.add(id)
try {
let cidrs: string[]
let configJson = list.configJson
if (id === listId && root.cidrs !== undefined) cidrs = uniqCidrs(root.cidrs)
else if (isManualListType(list.type)) {
const config = getListConfig(list)
const items: ListConfigItem[] = []
const all: string[] = []
for (const item of readManualItems(list.configJson)) {
let resolved: string[]
if (item.kind === 'hostname') resolved = await resolveHostnameToCidrs(item.value)
else if (item.kind === 'list') {
const child = lists.get(item.value)
if (!child) throw new Error(`Вложенный список не найден: ${item.value}`)
const materialized = repos.listIpListEntries(db, child.id).map((entry) => entry.cidr)
resolved = closure.has(child.id) || (isManualListType(child.type) && materialized.length === 0)
? await stage(child.id) : materialized
} else resolved = normalizeItemToCidrs(item)
items.push({ ...item, resolved_cidrs: resolved })
for (const cidr of resolved) all.push(cidr)
}
config.items = items
delete config.domains
configJson = JSON.stringify(config)
cidrs = uniqCidrs(all)
} else cidrs = uniqCidrs(repos.listIpListEntries(db, id).map((entry) => entry.cidr))
staged.set(id, { cidrs, configJson, type: list.type })
return cidrs
} finally { visiting.delete(id) }
}
for (const id of closure) await stage(id)
const changed: string[] = []
db.transaction((tx) => {
const current = repos.listIpLists(tx)
if (current.length !== snapshot.length || snapshot.some((before) => {
const now = current.find((list) => list.id === before.id)
return !now || now.configJson !== before.configJson || now.contentHash !== before.contentHash || now.updatedAt !== before.updatedAt
})) throw new Error('Lists changed while resolving; retry refresh')
for (const [id, value] of staged) {
if (repos.replaceIpListEntries(tx, id, value.cidrs)) changed.push(id)
repos.updateIpList(tx, id, {
type: value.type,
configJson: value.configJson,
contentHash: `sha256:${createHash('sha256').update(value.cidrs.join('\n')).digest('hex')}`,
refreshedAt: new Date().toISOString(),
lastError: null,
})
}
const alreadyBumped = new Set<string>()
for (const id of changed) repos.bumpAgentsForList(tx, id, alreadyBumped)
})
return changed
}
/** Rebuild ip_list_entries from config.items (manual lists). */
export async function rebuildManualListEntries(
db: Db,
listId: string,
visiting: Set<string> = new Set(),
): Promise<string[]> {
const list = repos.getIpList(db, listId)
if (!list || !isManualListType(list.type)) return []
if (visiting.has(listId)) {
throw new Error(`Циклическая ссылка списков: ${listId}`)
}
visiting.add(listId)
try {
const config = getListConfig(list)
const items = readManualItems(list.configJson)
const nextItems: ListConfigItem[] = []
const all: string[] = []
for (const item of items) {
try {
const cidrs = await expandItem(
db,
{ ...item, resolved_cidrs: undefined },
visiting,
)
nextItems.push({ ...item, resolved_cidrs: cidrs })
all.push(...cidrs)
} catch {
nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] })
if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs)
}
}
config.items = nextItems
delete config.domains
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
const cidrs = uniqCidrs(all)
repos.replaceIpListEntries(db, listId, cidrs)
return cidrs
} finally {
visiting.delete(listId)
}
export async function rebuildManualListEntries(db: Db, listId: string, _visiting: Set<string> = new Set()): Promise<string[]> {
await publishListCascade(db, listId)
return repos.listIpListEntries(db, listId).map((entry) => entry.cidr)
}
/** Rebuild list and all ancestor lists that nest it; bump agents. */
export async function rebuildListCascade(
db: Db,
listId: string,
): Promise<string[]> {
const affected: string[] = []
const queue = [listId]
const seen = new Set<string>()
while (queue.length) {
const id = queue.shift()!
if (seen.has(id)) continue
seen.add(id)
const list = repos.getIpList(db, id)
if (!list) continue
if (isManualListType(list.type)) {
await rebuildManualListEntries(db, id)
}
affected.push(id)
for (const parentId of findParentListIds(db, id)) {
if (!seen.has(parentId)) queue.push(parentId)
}
}
for (const id of affected) {
repos.bumpAgentsForList(db, id)
}
return affected
export async function rebuildListCascade(db: Db, listId: string): Promise<string[]> {
return publishListCascade(db, listId)
}
function normalizeInputItem(input: ListEntryInput): ListConfigItem {
@@ -170,16 +170,17 @@ function normalizeInputItem(input: ListEntryInput): ListConfigItem {
return { kind: 'list', value: input.value.trim() }
}
if (input.kind === 'hostname') {
return { kind: 'hostname', value: input.value.trim().toLowerCase() }
return { kind: 'hostname', value: canonicalHostname(input.value) }
}
return { kind: input.kind, value: input.value.trim() }
return { kind: input.kind, value: input.kind === 'ip' ? canonicalIp(input.value) : canonicalCidr(input.value) }
}
export async function addListEntries(
async function mutateListEntries(
db: Db,
listId: string,
input: { values?: string[]; items?: ListEntryInput[] },
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
allowNoop: boolean,
): Promise<ListImportSummary> {
const list = repos.getIpList(db, listId)
if (!list) throw new Error('List not found')
if (!isManualListType(list.type)) {
@@ -218,14 +219,14 @@ export async function addListEntries(
const key = `${parsed.kind}:${parsed.value.toLowerCase()}`
if (existing.has(key)) continue
const resolved = await expandItem(db, parsed, new Set([listId]))
const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved }
const item: ListConfigItem = parsed
items.push(item)
existing.add(key)
added.push(item)
}
if (added.length === 0) {
if (allowNoop) return { ...repos.getIpListEntrySummary(db, listId)!, changed: false }
throw new Error('Нет новых записей для добавления')
}
@@ -233,16 +234,17 @@ export async function addListEntries(
delete config.domains
const type = list.type === 'domains' ? 'static' : list.type
repos.updateIpList(db, listId, {
type,
configJson: JSON.stringify(config),
})
const changed = await publishListCascade(db, listId, { type, configJson: JSON.stringify(config) })
await rebuildListCascade(db, listId)
repos.updateIpList(db, listId, {
refreshedAt: new Date().toISOString(),
lastError: null,
})
return { ...repos.getIpListEntrySummary(db, listId)!, changed: changed.includes(listId) }
}
export async function addListEntriesSummary(db: Db, listId: string, input: { values?: string[]; items?: ListEntryInput[] }): Promise<ListImportSummary> {
return mutateListEntries(db, listId, input, true)
}
export async function addListEntries(db: Db, listId: string, input: { values?: string[]; items?: ListEntryInput[] }): Promise<{ items: ListConfigItem[]; entries: string[] }> {
await mutateListEntries(db, listId, input, false)
return {
items: readManualItems(repos.getIpList(db, listId)!.configJson),
@@ -250,11 +252,11 @@ export async function addListEntries(
}
}
export async function deleteListEntry(
async function mutateDeleteListEntry(
db: Db,
listId: string,
value: string,
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
): Promise<ListImportSummary> {
const list = repos.getIpList(db, listId)
if (!list) throw new Error('List not found')
if (!isManualListType(list.type)) {
@@ -274,16 +276,17 @@ export async function deleteListEntry(
delete config.domains
const type = list.type === 'domains' ? 'static' : list.type
repos.updateIpList(db, listId, {
type,
configJson: JSON.stringify(config),
})
const changed = await publishListCascade(db, listId, { type, configJson: JSON.stringify(config) })
await rebuildListCascade(db, listId)
repos.updateIpList(db, listId, {
refreshedAt: new Date().toISOString(),
lastError: null,
})
return { ...repos.getIpListEntrySummary(db, listId)!, changed: changed.includes(listId) }
}
export async function deleteListEntrySummary(db: Db, listId: string, value: string): Promise<ListImportSummary> {
return mutateDeleteListEntry(db, listId, value)
}
export async function deleteListEntry(db: Db, listId: string, value: string): Promise<{ items: ListConfigItem[]; entries: string[] }> {
await mutateDeleteListEntry(db, listId, value)
return {
items: readManualItems(repos.getIpList(db, listId)!.configJson),
@@ -291,6 +294,19 @@ export async function deleteListEntry(
}
}
export function mapListDetailPage(db: Db, listId: string, query: ListDetailPageQuery) {
const page = repos.getListDetailPage(db, listId, query)
if (!page) return null
const items = page.rows.map((row) => {
let resolved = JSON.parse(row.resolved_json) as string[]
if (resolved.length === 0 && (row.kind === 'ip' || row.kind === 'cidr')) resolved = normalizeItemToCidrs(row)
const count = row.stored_count || resolved.length
return { kind: row.kind, value: row.value, list_name: row.list_name, resolved_count: count, resolved_cidrs: resolved, resolved_truncated: count > resolved.length }
})
return { ...page.metadata, config_json: '{}', total: page.total, limit: query.limit, offset: query.offset,
items, entries: items.flatMap((item) => item.resolved_cidrs) }
}
export function mapListDetail(db: Db, listId: string) {
const l = repos.getIpList(db, listId)
if (!l) return null
+70 -86
View File
@@ -1,51 +1,47 @@
import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
import {
findParentListIds,
rebuildListCascade,
rebuildManualListEntries,
} from './entries.js'
import { rebuildListCascade, publishListCascade } from './entries.js'
import { uniqCidrs } from '../uniq.js'
import { decryptSecret } from '../secret-cipher.js'
import { fetchEvobgpPages } from '../evobgp.js'
import { safeFetchJson, SOURCE_LIMITS } from '../safe-fetch.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
}
async function fetchJsonUrl(url: string): Promise<string[]> {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
})
if (!res.ok) throw new Error(`JSON URL HTTP ${res.status}`)
const data = (await res.json()) as unknown
const { data } = await safeFetchJson(url)
const out: string[] = []
let recognized = false
const push = (v: unknown) => {
if (typeof v === 'string' && v.trim()) out.push(v.trim())
if (out.length >= SOURCE_LIMITS.elements) throw new Error('JSON prefix element limit exceeded')
if (typeof v !== 'string' || !v.trim()) throw new Error('Invalid JSON prefix item')
out.push(v.trim())
}
if (Array.isArray(data)) {
recognized = true
for (const item of data) {
if (typeof item === 'string') push(item)
else if (item && typeof item === 'object') {
const o = item as Record<string, unknown>
push(o.cidr ?? o.prefix ?? o.ip ?? o.network)
}
} else throw new Error('Invalid JSON prefix item')
}
} else if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const arr = (o.prefixes ?? o.cidrs ?? o.ips ?? o.items) as unknown
if (Array.isArray(arr)) {
recognized = true
for (const item of arr) {
if (typeof item === 'string') push(item)
else if (item && typeof item === 'object') {
const x = item as Record<string, unknown>
push(x.cidr ?? x.prefix ?? x.ip)
}
} else throw new Error('Invalid JSON prefix item')
}
}
}
if (!recognized) throw new Error('Invalid JSON prefix response')
return uniqCidrs(out)
}
@@ -62,20 +58,12 @@ async function resolveEvobgpCommunityId(
if (UUID_RE.test(key)) return key
// Heal lists created when Autocomplete stored label instead of UUID.
const res = await fetch(`${base}/v1/communities?limit=200`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(20_000),
const pages = await fetchEvobgpPages(base, token, '/v1/communities', 200)
const items = pages.flatMap((page) => {
if (!Array.isArray(page.items)) throw new Error('Invalid EvoBGP communities')
return page.items as { id?: string; community?: string; title?: string | null }[]
})
if (!res.ok) {
throw new Error(`EvoBGP communities HTTP ${res.status}`)
}
const data = (await res.json()) as {
items?: { id?: string; community?: string; title?: string | null }[]
}
const hit = (data.items ?? []).find((c) => {
const hit = items.find((c) => {
if (!c.id || !c.community) return false
if (c.id === key || c.community === key) return true
if (c.title && `${c.community} · ${c.title}` === key) return true
@@ -87,40 +75,50 @@ async function resolveEvobgpCommunityId(
return hit.id
}
async function fetchEvobgpCommunity(
apiUrl: string,
token: string,
communityId: string,
): Promise<{ cidrs: string[]; resolvedId: string }> {
async function fetchEvobgpCommunity(apiUrl: string, token: string, communityId: string): Promise<{ cidrs: string[]; resolvedId: string }> {
const base = apiUrl.replace(/\/$/, '')
const resolvedId = await resolveEvobgpCommunityId(base, token, communityId)
const url = `${base}/v1/communities/${encodeURIComponent(resolvedId)}/prefixes?limit=5000`
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(45_000),
const pages = await fetchEvobgpPages(base, token, `/v1/communities/${encodeURIComponent(resolvedId)}/prefixes`, 5000)
const cidrs = pages.flatMap((page) => {
if (Array.isArray(page.prefixes)) return page.prefixes as string[]
if (Array.isArray(page.items)) return page.items.map((item) => {
if (!item || typeof item !== 'object' || typeof item.prefix !== 'string') throw new Error('Invalid EvoBGP prefix item')
return item.prefix as string
})
throw new Error('Invalid EvoBGP prefix page')
})
if (!res.ok) {
throw new Error(`EvoBGP community prefixes HTTP ${res.status}`)
}
const data = (await res.json()) as {
items?: { prefix?: string }[]
prefixes?: string[]
}
let cidrs: string[] = []
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
cidrs = uniqCidrs(data.prefixes)
} else if (Array.isArray(data.items)) {
cidrs = uniqCidrs(data.items.map((i) => i.prefix ?? '').filter(Boolean))
}
return { cidrs, resolvedId }
return { cidrs: uniqCidrs(cidrs), resolvedId }
}
export async function refreshIpList(db: Db, listId: string): Promise<void> {
let activeRefreshes = 0
const waiting: (() => void)[] = []
const refreshing = new WeakMap<Db, Map<string, Promise<void>>>()
async function withRefreshSlot(work: () => Promise<void>): Promise<void> {
if (activeRefreshes >= 2) await new Promise<void>(resolve => waiting.push(resolve))
else activeRefreshes++
try { await work() } finally {
const next = waiting.shift()
if (next) next()
else activeRefreshes--
}
}
/** Coalesce duplicate triggers per DB/list; at most two refreshes process-wide. */
export function refreshIpList(db: Db, listId: string): Promise<void> {
let running = refreshing.get(db)
if (!running) { running = new Map(); refreshing.set(db, running) }
const existing = running.get(listId)
if (existing) return existing
const task = withRefreshSlot(() => performRefresh(db, listId)).finally(() => running!.delete(listId))
running.set(listId, task)
return task
}
async function performRefresh(db: Db, listId: string): Promise<void> {
const list = repos.getIpList(db, listId)
if (!list) return
const sourceSettings = [repos.getSetting(db, 'evobgp_api_url'), repos.getSetting(db, 'evobgp_api_token')]
let config: Record<string, unknown> = {}
try {
@@ -132,12 +130,14 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
try {
let cidrs: string[] = []
if (list.type === 'static' || list.type === 'domains') {
cidrs = await rebuildManualListEntries(db, listId)
await rebuildListCascade(db, listId)
repos.updateIpList(db, listId, { refreshedAt: new Date().toISOString(), lastError: null })
return
} else if (list.type === 'json_url') {
const url = String(config.url ?? '')
if (!url) throw new Error('config.url required')
cidrs = await fetchJsonUrl(url)
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') {
const apiUrl =
String(config.api_url ?? '') ||
@@ -152,39 +152,23 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
}
const fetched = await fetchEvobgpCommunity(apiUrl, token, communityId)
cidrs = fetched.cidrs
repos.replaceIpListEntries(db, listId, cidrs)
// Persist resolved UUID if list was saved with autocomplete label.
if (fetched.resolvedId !== communityId) {
repos.updateIpList(db, listId, {
configJson: JSON.stringify({
...config,
community_id: fetched.resolvedId,
}),
})
config.community_id = fetched.resolvedId
}
}
} else throw new Error('Unsupported list source type')
const contentHash = hashCidrs(cidrs)
repos.updateIpList(db, listId, {
contentHash,
refreshedAt: new Date().toISOString(),
lastError: null,
})
// Cascade to parents that nest this list (skip self rebuild for non-manual —
// CIDRs already replaced above).
if (list.type === 'static' || list.type === 'domains') {
await rebuildListCascade(db, listId)
} else {
repos.bumpAgentsForList(db, listId)
for (const parentId of findParentListIds(db, listId)) {
await rebuildListCascade(db, parentId)
}
if (!cidrs.length && config.allow_empty !== true) throw new Error('Empty source snapshot quarantined; set config.allow_empty=true to publish it')
if (list.type === 'evobgp_community' && sourceSettings.some((value, index) => value !== repos.getSetting(db, index === 0 ? 'evobgp_api_url' : 'evobgp_api_token'))) {
throw new Error('EvoBGP settings changed during refresh; retry refresh')
}
await publishListCascade(db, listId, { cidrs, configJson: JSON.stringify(config), expected: list })
} catch (err) {
const current = repos.getIpList(db, listId)
if (!current || current.configJson !== list.configJson || current.type !== list.type || current.contentHash !== list.contentHash || current.updatedAt !== list.updatedAt) return
repos.updateIpList(db, listId, {
lastError: err instanceof Error ? err.message : String(err),
refreshedAt: new Date().toISOString(),
lastError: (err instanceof Error ? err.message : String(err)).slice(0, 512),
})
}
}
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createHash } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { repos, createMemoryDb, runMigrations, appendAudit } from '@evofw/db'
import { buildApp } from '../app.js'
import { loadConfig, trustedProxyCidrs } from '../config.js'
import { IdentityLimiter, routeSecurityFor } from '../plugins/operational.js'
import { AuditOutbox } from './audit-outbox.js'
import { ingestAuditEventSchema } from '@evofw/shared'
const opened: Awaited<ReturnType<typeof buildApp>>[] = []
const temporary: string[] = []
afterEach(async () => {
for (const app of opened.splice(0)) await app.close()
for (const dir of temporary.splice(0)) rmSync(dir, { recursive: true, force: true })
vi.restoreAllMocks(); vi.unstubAllEnvs()
})
async function application(extra = {}) {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: true, staticDir: null, authAuditIngestSecret: null, logLevel: 'silent', ...extra } })
opened.push(app); await app.ready(); return app
}
const userToken = (app: Awaited<ReturnType<typeof application>>, id = 'operator', permissions = ['fw:settings:admin']) => app.jwt.sign({ sub: id, apps: ['fw'], permissions, iss: loadConfig().authIssuer })
describe('operational request guards', () => {
it('covers every protected route, fails unknown mappings and preserves the filtered-summary exception', async () => {
const app = await application()
const routes = app.routeSecurity.filter(route => route.url.startsWith('/api/v1/'))
expect(routes.length).toBeGreaterThan(30)
for (const route of routes) {
expect(route.policy === 'protected' ? route.permission : route.url === '/api/v1/ui/summary' || route.url === '/api/v1/auth/config').toBeTruthy()
}
expect(routeSecurityFor('GET', '/api/v1/ui/summary').policy).toBe('section-filtered')
expect(routeSecurityFor('GET', '/api/v1/agents/:id/stats/raw').permission).toBe('fw:stats:read')
expect(routeSecurityFor('GET', '/api/v1/stats/export.csv').permission).toBe('fw:stats:read')
expect(routeSecurityFor('GET', '/api/v1/agents/:id/explain').permission).toBe('fw:agents:read')
expect(() => routeSecurityFor('GET', '/api/v1/unmapped-feature')).toThrow('lacks permission')
expect(() => routeSecurityFor('GET', '/v1/agent/unmapped-feature')).toThrow('authentication mapping')
const token = userToken(app, 'limited', ['fw:lists:read'])
const summary = await app.inject({ url: '/api/v1/ui/summary', headers: { authorization: `Bearer ${token}` } })
expect(summary.statusCode).toBe(200); expect(summary.json().lists).toBeDefined(); expect(summary.json().agents).toBeUndefined()
expect((await app.inject({ url: '/api/v1/diagnostics', headers: { authorization: `Bearer ${token}` } })).statusCode).toBe(403)
})
it('accepts 100 authenticated agents with reports, exact retries and concurrent summary reads behind one NAT', async () => {
const app = await application()
for (let i = 0; i < 100; i++) repos.insertAgent(app.db, { id: `nat-${i}`, name: `nat-${i}`, tokenPrefix: `nat-${i}`, tokenHash: createHash('sha256').update(`token-${i}`).digest('hex'), status: 'approved' })
const statuses: number[] = []
const token = userToken(app, 'reader', ['fw:stats:read'])
let next = 0
await Promise.all(Array.from({ length: 10 }, async () => {
while (next < 100) {
const i = next++
const headers = { authorization: `Bearer token-${i}` }
const remoteAddress = '198.51.100.44'
for (let sequence = 1; sequence <= 3; sequence++) {
const payload = { telemetry_version: 2, report_id: `nat-report-${sequence}`, sequence, boot_id: 'boot', observed_at: new Date(Date.now() - (4 - sequence) * 60_000).toISOString(), interval_ms: 60_000, counter_epoch: { traffic: 'traffic' }, capabilities: ['policy_ipv4'], quality: { partial: false, truncated: false, unsupported: [], lost_reports: 0 }, status: 'ok', packets_dropped: sequence * 100, packets_accepted: sequence * 200, detail_counts: { ip_found: 0, ip_sent: 0, port_found: 0, port_sent: 0 } }
for (let retry = 0; retry < 2; retry++) statuses.push((await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers, payload, remoteAddress })).statusCode)
}
statuses.push((await app.inject({ url: '/v1/agent/policy', headers, remoteAddress })).statusCode)
statuses.push((await app.inject({ url: '/api/v1/ui/summary', headers: { authorization: `Bearer ${token}` }, remoteAddress })).statusCode)
}
}))
expect(statuses).toHaveLength(800); expect(new Set(statuses)).toEqual(new Set([200]))
for (let i = 0; i < 100; i++) expect(repos.getAgent(app.db, `nat-${i}`)!.totalPacketsDropped).toBe(200)
}, 30_000)
it('bounds identity buckets and keeps independent identities/enrollment limits', async () => {
const limiter = new IdentityLimiter(2)
expect(limiter.take('a', 1, 0)).toBe(true); expect(limiter.take('a', 1, 1)).toBe(false)
expect(limiter.take('b', 1, 1)).toBe(true); expect(limiter.take('c', 1, 1)).toBe(false)
expect(limiter.take('c', 1, 60_001)).toBe(true); expect(limiter.stats().buckets).toBeLessThanOrEqual(2)
const app = await application()
for (let i = 0; i < 20; i++) expect((await app.inject({ method: 'POST', url: '/v1/agent/enroll', payload: {}, remoteAddress: '198.51.100.44' })).statusCode).not.toBe(429)
expect((await app.inject({ method: 'POST', url: '/v1/agent/enroll', payload: {}, remoteAddress: '198.51.100.44' })).statusCode).toBe(429)
})
it('uses explicit proxy trust for audit IP and never includes settings values in local/outbox audit', async () => {
expect(trustedProxyCidrs(undefined)).toBe(false)
expect(() => trustedProxyCidrs('true')).toThrow('explicit')
for (const trustProxy of [false, ['127.0.0.1/32']] as const) {
const app = await application({ trustProxy, authAuditIngestSecret: 'portal-ingest-secret' })
const token = userToken(app)
const response = await app.inject({ method: 'PUT', url: '/api/v1/settings', headers: { authorization: `Bearer ${token}`, 'x-forwarded-for': '203.0.113.8' }, remoteAddress: '127.0.0.1', payload: { evobgp_api_token: 'NEVER-AUDIT-THIS-SECRET' } })
expect(response.statusCode).toBe(200)
const audit = app.sqlite.prepare("SELECT details_json,ip FROM audit_log WHERE action='settings.update'").get() as { details_json: string; ip: string }
expect(audit.ip).toBe(trustProxy === false ? '127.0.0.1' : '203.0.113.8')
expect(JSON.parse(audit.details_json)).toEqual({ changed_keys: ['evobgp_api_token'] })
const queued = app.sqlite.prepare('SELECT payload_json FROM audit_outbox').get() as { payload_json: string }
expect(queued.payload_json).not.toContain('NEVER-AUDIT-THIS-SECRET'); expect(queued.payload_json).not.toContain('portal-ingest-secret')
}
})
it('rate-limits failed authentication while a valid agent behind the same NAT remains usable', async () => {
const app = await application()
repos.insertAgent(app.db, { id: 'valid', name: 'valid', tokenPrefix: 'valid', tokenHash: createHash('sha256').update('valid-token').digest('hex'), status: 'approved' })
const remoteAddress = '198.51.100.44'
for (let i = 0; i < 60; i++) expect((await app.inject({ url: '/v1/agent/policy', remoteAddress, headers: { authorization: 'Bearer invalid' } })).statusCode).toBe(401)
expect((await app.inject({ url: '/v1/agent/policy', remoteAddress, headers: { authorization: 'Bearer invalid' } })).statusCode).toBe(429)
expect((await app.inject({ url: '/v1/agent/policy', remoteAddress, headers: { authorization: 'Bearer valid-token' } })).statusCode).toBe(200)
})
it('serves JSON 404 for unknown API paths when SPA fallback is enabled', async () => {
const dir = mkdtempSync(join(tmpdir(), 'evofw-spa-')); temporary.push(dir); writeFileSync(join(dir, 'index.html'), '<html>SPA</html>')
const app = await application({ staticDir: dir })
const headers = { authorization: `Bearer ${userToken(app)}` }
for (const url of ['/api/v1/not-a-route', '/v1/agent/not-a-route', '/api']) {
const response = await app.inject({ url, headers }); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe('NOT_FOUND')
}
expect((await app.inject({ url: '/agents', headers })).body).toContain('SPA')
})
})
describe('durable audit delivery', () => {
it('persists retries across worker recreation and acknowledges only successful batches', async () => {
const { sqlite } = createMemoryDb(); runMigrations(sqlite)
const config = { ...loadConfig(), authAuditIngestSecret: 'secret', authPortalUrl: 'https://portal.test' }
const first = new AuditOutbox(sqlite, config)
const event = { event_id: 'event-one', source_app: 'fw', action: 'test', severity: 'info', summary: 'test', created_at: new Date().toISOString() } as const
try {
first.enqueue(event)
const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 503 }))
await first.flush(1000)
expect(first.size()).toBe(1)
const row = sqlite.prepare('SELECT attempts,next_attempt_ms FROM audit_outbox').get() as { attempts: number; next_attempt_ms: number }
expect(row.attempts).toBe(1); expect(row.next_attempt_ms).toBe(6000)
await first.close()
const restarted = new AuditOutbox(sqlite, config)
await restarted.flush(5999); expect(fetcher).toHaveBeenCalledTimes(1)
fetcher.mockResolvedValue(new Response('', { status: 200 }))
await restarted.flush(6000); expect(restarted.size()).toBe(0)
const sent = JSON.parse(fetcher.mock.calls[1]![1]!.body as string)
expect(sent.events[0].event_id).toBe('event-one')
await restarted.close()
} finally { sqlite.close() }
})
it('aborts an active worker on shutdown and retains queued events', async () => {
const { sqlite } = createMemoryDb(); runMigrations(sqlite)
const queue = new AuditOutbox(sqlite, { ...loadConfig(), authAuditIngestSecret: 'secret' })
queue.enqueue({ event_id: 'active', source_app: 'fw', action: 'test', severity: 'info', summary: 'test', created_at: new Date().toISOString() })
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, options) => new Promise((_, reject) => options!.signal!.addEventListener('abort', () => reject(new Error('aborted')))))
const pending = queue.flush()
await queue.close(); await pending
expect(queue.size()).toBe(1)
sqlite.close()
})
it('rejects queue/payload exhaustion before unbounded allocation and exposes bounded backpressure', async () => {
const app = await application({ authAuditIngestSecret: 'secret' })
const insert = app.sqlite.prepare('INSERT INTO audit_outbox(event_id,payload_json,created_at) VALUES (?,?,?)')
app.sqlite.transaction(() => { for (let i = 0; i < 10_000; i++) insert.run(`full-${i}`, '{}', new Date().toISOString()) })()
const settingBefore = repos.getSetting(app.db, 'show_quick_actions')
const response = await app.inject({ method: 'PUT', url: '/api/v1/settings', headers: { authorization: `Bearer ${userToken(app)}` }, payload: { show_quick_actions: 'false' } })
expect(response.statusCode).toBe(503)
expect(repos.getSetting(app.db, 'show_quick_actions')).toBe(settingBefore)
expect(app.auditOutbox.stats().pending).toBe(10_000)
expect(() => app.auditOutbox.enqueue({ event_id: 'overflow', source_app: 'fw', action: 'test', severity: 'info', summary: 'test', details: { data: 'x'.repeat(17_000) }, created_at: new Date().toISOString() })).toThrow('byte limit')
})
it('normalizes a real long-name mutation and dev actor to the portal contract', async () => {
const app = await application({ authRequired: false, authAuditIngestSecret: 'secret' })
const response = await app.inject({ method: 'POST', url: '/api/v1/lists', payload: { name: 'Long name '.repeat(70), type: 'static' } })
expect(response.statusCode).toBe(200)
const row = app.sqlite.prepare('SELECT payload_json FROM audit_outbox').get() as { payload_json: string }
const event = JSON.parse(row.payload_json)
expect(ingestAuditEventSchema.safeParse(event).success).toBe(true)
expect(event.summary.length).toBeLessThanOrEqual(500); expect(event.summary.length).toBeGreaterThan(480); expect(event.actor_email).toBeNull()
expect(app.sqlite.prepare('SELECT count(*) AS n FROM audit_log').get()).toEqual({ n: 1 })
})
it('quarantines stored malformed rows, repairs known legacy fields, and delivers healthy rows without poisoning', async () => {
const app = await application({ authAuditIngestSecret: 'secret' })
const insert = app.sqlite.prepare('INSERT INTO audit_outbox(event_id,payload_json,created_at) VALUES (?,?,?)')
const created_at = new Date().toISOString()
insert.run('malformed', 'not JSON private-content', created_at)
appendAudit(app.db, { eventId: 'malformed', action: 'test', summary: 'Retained local audit event' })
insert.run('legacy', JSON.stringify({ event_id: 'legacy', source_app: 'fw', action: 'test', summary: 'x'.repeat(512), actor_email: 'dev@local', created_at }), created_at)
app.auditOutbox.enqueue({ event_id: 'healthy', source_app: 'fw', action: 'test', summary: 'valid', created_at })
const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, options) => {
const { events } = JSON.parse(options!.body as string)
for (const event of events) expect(ingestAuditEventSchema.safeParse(event).success).toBe(true)
expect(events.map((event: { event_id: string }) => event.event_id)).toEqual(['legacy', 'healthy'])
return new Response('', { status: 200 })
})
await app.auditOutbox.flush()
expect(fetcher).toHaveBeenCalledTimes(1)
expect(app.auditOutbox.stats()).toMatchObject({ pending: 0, quarantined: 1 })
expect(JSON.stringify(app.auditOutbox.stats())).not.toContain('private-content')
for (let i = 0; i < 110; i++) insert.run(`invalid-${i}`, '{}', created_at)
for (let i = 0; i < 5; i++) await app.auditOutbox.flush()
expect(app.auditOutbox.stats().quarantined).toBe(100)
expect(fetcher).toHaveBeenCalledTimes(1)
expect(app.sqlite.prepare('SELECT summary FROM audit_log WHERE event_id=?').get('malformed')).toEqual({ summary: 'Retained local audit event' })
})
})
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { buildApp } from '../../app.js'
import { loadConfig } from '../../config.js'
import { hashToken } from '../../plugins/auth.js'
import { evaluateAgentPolicy } from './evaluate.js'
import { policyCacheFor } from './cache.js'
import { refreshAllHostnameRules } from './resolve-hostname.js'
const dns = vi.hoisted(() => ({ resolve4: vi.fn(), resolve6: vi.fn(), cancel: vi.fn() }))
vi.mock('node:dns/promises', async (original) => ({ ...await original<typeof import('node:dns/promises')>(), Resolver: class { resolve4 = dns.resolve4; resolve6 = dns.resolve6; cancel = dns.cancel } }))
const opened: ReturnType<typeof createMemoryDb>[] = []
const apps: Awaited<ReturnType<typeof buildApp>>[] = []
afterEach(async () => { for (const app of apps.splice(0)) await app.close(); for (const mem of opened.splice(0)) mem.sqlite.close(); vi.resetAllMocks() })
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
repos.insertAgent(mem.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' })
return mem
}
function hostnameFixture() {
const mem = memory(); repos.ensureSharedSetAssigned(mem.db, 'a')
for (const [id, hostname, cidr, priority] of [['h1', 'first.test', '192.0.2.1/32', 10], ['h2', 'second.test', '192.0.2.2/32', 20]] as const) {
repos.insertPolicyRule(mem.db, { id, setId: 'set-shared-default', hostname, action: 'deny', priority })
repos.replaceResolvedForRule(mem.db, id, [cidr])
}
dns.resolve6.mockResolvedValue([])
return mem
}
describe('cache assignment and DNS publication invalidation', () => {
it('refreshes cached preview set names after a rename without bumping again for an identical name', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } }); apps.push(app); await app.ready()
repos.insertAgent(app.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' })
repos.ensureSharedSetAssigned(app.db, 'a')
repos.insertPolicyRule(app.db, { id: 'deny', setId: 'set-shared-default', action: 'deny', cidr: '10.0.0.0/8', priority: 10 })
const before = (await app.inject({ url: '/api/v1/agents/a/preview' })).json()
const renamed = await app.inject({ method: 'PATCH', url: '/api/v1/policy-sets/set-shared-default', payload: { name: 'Renamed source set' } })
expect(renamed.statusCode).toBe(200)
const after = (await app.inject({ url: '/api/v1/agents/a/preview' })).json()
expect(after.chain[0].set_name).toBe('Renamed source set')
expect(after.generation).toBe(before.generation + 1)
expect(after.hash).toBe(before.hash)
await app.inject({ method: 'PATCH', url: '/api/v1/policy-sets/set-shared-default', payload: { name: 'Renamed source set' } })
expect((await app.inject({ url: '/api/v1/agents/a/preview' })).json().generation).toBe(after.generation)
})
it('invalidates a preview cached before single and bulk approval, with exactly one insertion bump and idempotent retries', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } }); apps.push(app); await app.ready()
repos.insertPolicyRule(app.db, { id: 'deny', setId: 'set-shared-default', action: 'deny', cidr: '10.0.0.0/8', priority: 10 })
for (const id of ['single', 'bulk']) {
repos.insertAgent(app.db, { id, name: id, tokenPrefix: id, tokenHash: hashToken(`token-${id}`), status: 'pending' })
const preview = await app.inject({ url: `/api/v1/agents/${id}/preview` })
expect(preview.statusCode).toBe(200)
expect(preview.json().summary.cidrs_deny).toBe(0)
expect(preview.json().generation).toBe(1)
}
const single = await app.inject({ method: 'POST', url: '/api/v1/agents/single/approve' })
expect(single.statusCode).toBe(200)
expect(single.json().policy_generation).toBe(2)
const bulk = await app.inject({ method: 'POST', url: '/api/v1/agents/approve-bulk', payload: { agent_ids: ['bulk'] } })
expect(bulk.statusCode).toBe(200)
expect(bulk.json().items[0].policy_generation).toBe(2)
for (const id of ['single', 'bulk']) {
const policy = await app.inject({ url: '/v1/agent/policy', headers: { authorization: `Bearer token-${id}` } })
expect(policy.statusCode).toBe(200)
expect(policy.json()).toMatchObject({ generation: 2, deny_cidrs: ['10.0.0.0/8'] })
expect(repos.ensureSharedSetAssigned(app.db, id)).toBe(false)
expect(repos.getAgent(app.db, id)!.policyGeneration).toBe(2)
}
expect((await app.inject({ method: 'POST', url: '/api/v1/agents/single/approve' })).json().policy_generation).toBe(2)
expect((await app.inject({ method: 'POST', url: '/api/v1/agents/approve-bulk', payload: { agent_ids: ['bulk'] } })).json().items).toEqual([])
expect(repos.getAgent(app.db, 'bulk')!.policyGeneration).toBe(2)
})
it('rolls back assignment insertion when the generation bump fails, and existing assignments never bump again', () => {
const mem = memory()
mem.sqlite.exec("CREATE TRIGGER reject_generation BEFORE UPDATE OF policy_generation ON agents BEGIN SELECT RAISE(ABORT,'test generation failure'); END")
expect(() => repos.ensureSharedSetAssigned(mem.db, 'a')).toThrow(/generation failure/)
expect(repos.listSetsForAgent(mem.db, 'a')).toEqual([])
expect(repos.getAgent(mem.db, 'a')!.policyGeneration).toBe(1)
mem.sqlite.exec('DROP TRIGGER reject_generation')
expect(repos.ensureSharedSetAssigned(mem.db, 'a')).toBe(true)
expect(repos.ensureSharedSetAssigned(mem.db, 'a')).toBe(false)
expect(repos.getAgent(mem.db, 'a')!.policyGeneration).toBe(2)
})
it('keeps warm and cold previews on the old snapshot while another DNS lookup is pending, then publishes once atomically', async () => {
const mem = hostnameFixture()
const before = evaluateAgentPolicy(mem.db, 'a')
let release!: (value: string[]) => void
const second = new Promise<string[]>((resolve) => { release = resolve })
dns.resolve4.mockImplementation((host) => host === 'second.test' ? second : Promise.resolve(['203.0.113.1']))
const refresh = refreshAllHostnameRules(mem.db)
await vi.waitFor(() => expect(dns.resolve4).toHaveBeenCalledWith('second.test'))
expect(repos.listResolvedForRule(mem.db, 'h1').map((row) => row.cidr)).toEqual(['192.0.2.1/32'])
expect(evaluateAgentPolicy(mem.db, 'a')).toBe(before)
policyCacheFor(mem.db).get('a', 'force-cold-preview', Date.now())
expect(evaluateAgentPolicy(mem.db, 'a').hash).toBe(before.hash)
release(['203.0.113.2']); await refresh
const after = evaluateAgentPolicy(mem.db, 'a')
expect(after.denyCidrs).toEqual(['203.0.113.1/32', '203.0.113.2/32'])
expect(after.generation).toBe(before.generation + 1)
await refreshAllHostnameRules(mem.db)
expect(evaluateAgentPolicy(mem.db, 'a')).toBe(after)
})
it('preserves failed lookup snapshots and never overwrites a newer publication made while DNS was pending', async () => {
const mem = hostnameFixture()
let reject!: (reason: Error) => void
const second = new Promise<string[]>((_, fail) => { reject = fail })
dns.resolve4.mockImplementation((host) => host === 'second.test' ? second : Promise.resolve(['203.0.113.1']))
const refresh = refreshAllHostnameRules(mem.db)
await vi.waitFor(() => expect(dns.resolve4).toHaveBeenCalledWith('second.test'))
repos.replaceResolvedForRule(mem.db, 'h1', ['198.51.100.1/32'])
repos.bumpAgentsForSet(mem.db, 'set-shared-default')
const newer = evaluateAgentPolicy(mem.db, 'a')
reject(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' })); await refresh
expect(evaluateAgentPolicy(mem.db, 'a')).toBe(newer)
expect(newer.denyCidrs).toEqual(['192.0.2.2/32', '198.51.100.1/32'])
})
})
@@ -0,0 +1,198 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { POLICY_BUDGET, policyBudgetErrorEnvelopeSchema } from '@evofw/shared'
import { evaluateAgentPolicy } from './evaluate.js'
import { PolicyCache, policyCacheFor } from './cache.js'
import { buildApp } from '../../app.js'
import { loadConfig } from '../../config.js'
import { hashToken } from '../../plugins/auth.js'
import { createHash } from 'node:crypto'
import { publishListCascade } from '../lists/entries.js'
import { refreshAllHostnameRules } from './resolve-hostname.js'
const dns = vi.hoisted(() => ({ resolve4: vi.fn(), resolve6: vi.fn(), cancel: vi.fn() }))
vi.mock('node:dns/promises', async (original) => ({ ...await original<typeof import('node:dns/promises')>(), Resolver: class { resolve4 = dns.resolve4; resolve6 = dns.resolve6; cancel = dns.cancel } }))
const opened: ReturnType<typeof createMemoryDb>[] = []
const apps: Awaited<ReturnType<typeof buildApp>>[] = []
afterEach(async () => { for (const mem of opened.splice(0)) mem.sqlite.close(); for (const app of apps.splice(0)) await app.close() })
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
repos.insertAgent(mem.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' })
repos.ensureSharedSetAssigned(mem.db, 'a')
return mem
}
function source(mem: ReturnType<typeof memory>, n: number) {
repos.insertIpList(mem.db, { id: 'l', name: 'l', type: 'static' })
const insert = mem.sqlite.prepare("INSERT INTO ip_list_entries(id,list_id,cidr) VALUES(?,'l',?)")
mem.sqlite.transaction(() => { for (let i = 0; i < n; i++) insert.run(`c${i}`, `10.${i >>> 16}.${(i >>> 8) & 255}.${i & 255}/32`) })()
}
function rule(mem: ReturnType<typeof memory>, id = 'r', action: 'deny' | 'allow' = 'deny') {
repos.insertPolicyRule(mem.db, { id, setId: 'set-shared-default', priority: Number(id.slice(1)) || 10, action, listId: 'l' })
}
describe('bounded compiler and policy cache', () => {
it('accepts50k adjacent canonical memberships and rejects50001 including an overlapping covering CIDR before kernel collapse', () => {
const mem = memory(); source(mem, 50_000); rule(mem)
expect(evaluateAgentPolicy(mem.db, 'a').denyCidrs).toHaveLength(50_000)
mem.sqlite.prepare("INSERT INTO ip_list_entries(id,list_id,cidr) VALUES('extra','l','10.0.0.0/16')").run()
repos.bumpAgentsForList(mem.db, 'l')
expect(() => evaluateAgentPolicy(mem.db, 'a')).toThrow(/50000 effective/)
expect(repos.listIpListEntries(mem.db, 'l')).toHaveLength(50_001)
})
it('counts repeated raw list references before deduplication and rejects before unbounded expansion', () => {
const mem = memory(); source(mem, 50_000)
for (let i = 1; i <= 4; i++) rule(mem, `r${i}`)
expect(evaluateAgentPolicy(mem.db, 'a').denyCidrs).toHaveLength(50_000)
rule(mem, 'r5'); repos.bumpAgentsForSet(mem.db, 'set-shared-default')
expect(() => evaluateAgentPolicy(mem.db, 'a')).toThrow(/200000 raw/)
})
it('bounds source prefetch before materializing a huge set of unique list memberships', () => {
const mem = memory(); source(mem, 200_001); rule(mem)
expect(() => evaluateAgentPolicy(mem.db, 'a')).toThrow(/200000 raw/)
})
it('counts both transport expansions and actual source memberships, with all dualstack but explicit IPv4 scoped', () => {
const mem = memory()
repos.insertAgentPortRule(mem.db, { id: 'all', agentId: 'a', action: 'open', protocol: 'both', portStart: 22, portEnd: 22, srcKind: 'all' })
repos.insertAgentPortRule(mem.db, { id: 'v4', agentId: 'a', action: 'open', protocol: 'tcp', portStart: 23, portEnd: 23, srcKind: 'cidr', srcCidr: '0.0.0.0/0' })
const policy = evaluateAgentPolicy(mem.db, 'a')
expect(policy.portRules.filter((r) => r.id === 'all').map((r) => [r.id, r.protocol, r.srcCidrs])).toEqual([
['all', 'tcp', ['0.0.0.0/0', '::/0']], ['all', 'udp', ['0.0.0.0/0', '::/0']],
])
expect(policy.portRules.find((r) => r.id === 'v4')!.srcCidrs).toEqual(['0.0.0.0/0'])
const insert = mem.sqlite.prepare("INSERT INTO agent_port_rules(id,agent_id,action,protocol,port_start,port_end,src_kind) VALUES(?,'a','open','both',80,80,'all')")
mem.sqlite.transaction(() => { for (let i = 0; i < 500; i++) insert.run(`p${i}`) })()
repos.bumpAgentGeneration(mem.db, 'a')
expect(() => evaluateAgentPolicy(mem.db, 'a')).toThrow(/1000 serialized/)
})
it('counts shared L3 and expanded PortACL memberships together', () => {
const mem = memory(); source(mem, 25_000); rule(mem)
repos.insertAgentPortRule(mem.db, { id: 'p', agentId: 'a', action: 'open', protocol: 'both', portStart: 22, portEnd: 22, srcKind: 'list', listId: 'l' })
expect(() => evaluateAgentPolicy(mem.db, 'a')).toThrow(/50000 effective/)
})
it('rejects an oversized legacy JSON candidate before publishing rather than serving it truncated', () => {
const mem = memory()
repos.insertAgentPortRule(mem.db, { id: 'x'.repeat(POLICY_BUDGET.jsonBytes), agentId: 'a', action: 'close', protocol: 'tcp', portStart: 22, portEnd: 22, srcKind: 'all' })
expect(() => evaluateAgentPolicy(mem.db, 'a')).toThrow(/8MiB/)
expect(policyCacheFor(mem.db).stats.entries).toBe(0)
})
it('hits a DB-scoped cache, invalidates generations/settings/expiry, and interns identical content across agents', () => {
const mem = memory()
const now = '2026-01-01T00:00:00.000Z', expiry = '2026-01-01T00:01:00.000Z'
repos.insertOverride(mem.db, { id: 'o', agentId: 'a', action: 'deny', cidr: '8.8.8.8/32', expiresAt: expiry })
const first = evaluateAgentPolicy(mem.db, 'a', now)
expect(evaluateAgentPolicy(mem.db, 'a', now)).toBe(first)
repos.bumpAgentGeneration(mem.db, 'a')
const revised = evaluateAgentPolicy(mem.db, 'a', now)
expect(revised).not.toBe(first); expect(revised.hash).toBe(first.hash)
repos.setSetting(mem.db, 'agent_sync_interval_sec', '120')
expect(evaluateAgentPolicy(mem.db, 'a', now).syncIntervalSec).toBe(120)
expect(evaluateAgentPolicy(mem.db, 'a', expiry).denyCidrs).toEqual([])
repos.insertAgent(mem.db, { id: 'b', name: 'b', tokenPrefix: 'b', tokenHash: 'b', status: 'approved' })
const empty = evaluateAgentPolicy(mem.db, 'a', expiry), other = evaluateAgentPolicy(mem.db, 'b', expiry)
expect(empty.denyCidrs).toBe(other.denyCidrs)
expect(policyCacheFor(mem.db).stats).toMatchObject({ entries: 2, contents: 1 })
const secondDb = memory()
expect(evaluateAgentPolicy(secondDb.db, 'a', now)).not.toBe(empty)
})
it('evicts LRU entries and all unused intern references within conservative entry/byte budgets', () => {
const mem = memory(); const policy = evaluateAgentPolicy(mem.db, 'a')
const cache = new PolicyCache(2, 20_000)
cache.put('a', '1', policy, Infinity); cache.put('b', '1', policy, Infinity)
expect(cache.get('a', '1', 0)).toBeDefined()
cache.put('c', '1', { ...policy, hash: 'different', denyCidrs: ['8.8.8.8/32'] }, Infinity)
expect(cache.get('b', '1', 0)).toBeUndefined()
expect(cache.stats.entries).toBe(2)
cache.get('a', 'invalidated', 0); cache.get('c', 'invalidated', 0)
expect(cache.stats).toMatchObject({ entries: 0, contents: 0, bytes: 0 })
cache.put('huge', '1', { ...policy, chain: [{ ...policy.chain[0]!, sourceLabel: 'x'.repeat(20_000) }] }, Infinity)
expect(cache.stats.entries).toBe(0)
const bounded = new PolicyCache(128, 32 * 1024 * 1024)
for (let i = 0; i < 130; i++) bounded.put(String(i), '1', policy, Infinity)
expect(bounded.stats.entries).toBe(128)
expect(bounded.stats.contents).toBe(1)
expect(bounded.stats.bytes).toBeLessThanOrEqual(bounded.stats.maxBytes)
const pressure = new PolicyCache(128, 24_000)
for (let i = 0; i < 10; i++) pressure.put(String(i), '1', { ...policy, hash: String(i), denyCidrs: Array.from({ length: 50 }, (_, j) => `10.${i}.0.${j}/32`) }, Infinity)
expect(pressure.stats.entries).toBeGreaterThan(0)
expect(pressure.stats.entries).toBeLessThan(10)
expect(pressure.stats.contents).toBe(pressure.stats.entries)
expect(pressure.stats.bytes).toBeLessThanOrEqual(pressure.stats.maxBytes)
})
it('invalidates warm policies after actual PortACL list publication and hostname refresh, retaining unchanged cache hits', async () => {
const mem = memory()
repos.insertIpList(mem.db, { id: 'l', name: 'l', type: 'static' })
repos.insertAgentPortRule(mem.db, { id: 'p', agentId: 'a', action: 'open', protocol: 'tcp', portStart: 22, portEnd: 22, srcKind: 'list', listId: 'l' })
await publishListCascade(mem.db, 'l', { configJson: JSON.stringify({ items: [{ kind: 'ip', value: '8.8.8.8' }] }) })
const first = evaluateAgentPolicy(mem.db, 'a')
await publishListCascade(mem.db, 'l', { configJson: JSON.stringify({ items: [{ kind: 'ip', value: '9.9.9.9' }] }) })
const changed = evaluateAgentPolicy(mem.db, 'a')
expect(changed.hash).not.toBe(first.hash)
expect(changed.portRules[0]!.srcCidrs).toEqual(['9.9.9.9/32'])
await publishListCascade(mem.db, 'l')
expect(evaluateAgentPolicy(mem.db, 'a')).toBe(changed)
repos.insertPolicyRule(mem.db, { id: 'host', setId: 'set-shared-default', priority: 10, action: 'deny', hostname: 'test.example' })
dns.resolve4.mockResolvedValue(['192.0.2.1']); dns.resolve6.mockResolvedValue([])
await refreshAllHostnameRules(mem.db)
const resolved = evaluateAgentPolicy(mem.db, 'a')
expect(resolved.denyCidrs).toEqual(['192.0.2.1/32'])
dns.resolve4.mockResolvedValue(['192.0.2.2'])
await refreshAllHostnameRules(mem.db)
expect(evaluateAgentPolicy(mem.db, 'a').denyCidrs).toEqual(['192.0.2.2/32'])
expect(evaluateAgentPolicy(mem.db, 'a').hash).not.toBe(resolved.hash)
})
it('uses full revision-safe response ETags, preserves contact on304, rejects oversized candidates without changing lastgood, and exposes opt-in RouterOS budget errors', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } }); apps.push(app); await app.ready()
repos.insertAgent(app.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: hashToken('token'), status: 'approved' })
repos.ensureSharedSetAssigned(app.db, 'a')
const headers = { authorization: 'Bearer token' }
const first = await app.inject({ url: '/v1/agent/policy', headers })
expect(first.statusCode).toBe(200)
const etag = first.headers.etag!
expect(etag).toMatch(/^"policy-v1-[0-9a-f]{64}"$/)
expect(etag).toBe(`"policy-v1-${createHash('sha256').update(first.body).digest('hex')}"`)
repos.updateAgent(app.db, 'a', { lastSeenAt: '2000-01-01T00:00:00.000Z', appliedHash: first.json().hash, appliedRevision: first.json().generation })
const unchanged = await app.inject({ url: '/v1/agent/policy', headers: { ...headers, 'if-none-match': etag } })
expect(unchanged.statusCode).toBe(304); expect(unchanged.body).toBe('')
expect(repos.getAgent(app.db, 'a')!.lastSeenAt).not.toBe('2000-01-01T00:00:00.000Z')
repos.bumpAgentGeneration(app.db, 'a')
const revision = await app.inject({ url: '/v1/agent/policy', headers: { ...headers, 'if-none-match': etag } })
expect(revision.statusCode).toBe(200); expect(revision.headers.etag).not.toBe(etag)
expect(revision.json().hash).toBe(first.json().hash)
repos.setSetting(app.db, 'agent_sync_interval_sec', '120')
const setting = await app.inject({ url: '/v1/agent/policy', headers: { ...headers, 'if-none-match': revision.headers.etag! } })
expect(setting.statusCode).toBe(200); expect(setting.json().sync_interval_sec).toBe(120)
repos.insertOverride(app.db, { id: 'ttl', agentId: 'a', action: 'deny', cidr: '8.8.8.8/32', expiresAt: new Date(Date.now() + 60_000).toISOString() })
repos.bumpAgentGeneration(app.db, 'a')
const active = await app.inject({ url: '/v1/agent/policy', headers })
expect(active.json().hash).not.toBe(first.json().hash)
app.sqlite.prepare('UPDATE ip_overrides SET expires_at=? WHERE id=?').run(new Date(Date.now() - 1000).toISOString(), 'ttl')
const expired = await app.inject({ url: '/v1/agent/policy', headers: { ...headers, 'if-none-match': setting.headers.etag! } })
expect(expired.statusCode).toBe(200)
expect(expired.json().hash).toBe(first.json().hash)
expect(expired.json().generation).toBeGreaterThan(setting.json().generation)
expect(expired.headers.etag).not.toBe(setting.headers.etag)
const good = repos.getAgent(app.db, 'a')!
const insert = app.sqlite.prepare("INSERT INTO agent_port_rules(id,agent_id,action,protocol,port_start,port_end,src_kind) VALUES(?,'a','open','both',80,80,'all')")
app.sqlite.transaction(() => { for (let i = 0; i < 501; i++) insert.run(`p${i}`) })()
repos.bumpAgentGeneration(app.db, 'a')
const bad = await app.inject({ url: '/v1/agent/policy', headers })
expect(bad.statusCode).toBe(422); expect(bad.json().error.code).toBe('POLICY_BUDGET_EXCEEDED')
expect(repos.getAgent(app.db, 'a')).toMatchObject({ desiredHash: good.desiredHash, desiredHashGeneration: good.desiredHashGeneration, appliedHash: good.appliedHash, appliedRevision: good.appliedRevision })
const routeros = await app.inject({ url: '/v1/agent/policy?transport=routeros', headers })
expect(routeros.statusCode).toBe(200); expect(policyBudgetErrorEnvelopeSchema.safeParse(routeros.json()).success).toBe(true)
expect(routeros.json()).not.toHaveProperty('hash'); expect(routeros.headers.etag).toBeUndefined()
expect(routeros.headers['cache-control']).toBe('no-store')
expect((await app.inject({ url: '/v1/agent/policy?transport=anything', headers })).statusCode).toBe(400)
expect((await app.inject({ url: '/v1/agent/policy?transport=routeros' })).statusCode).toBe(401)
})
})
+74
View File
@@ -0,0 +1,74 @@
import type { Db } from '@evofw/db'
import type { EvaluatedPolicy } from './evaluate.js'
type Content = Pick<EvaluatedPolicy, 'denyCidrs' | 'allowCidrs' | 'portRules'>
type Entry = { key: string; policy: EvaluatedPolicy; contentKey: string; bytes: number; expiresAt: number }
type Intern = { content: Content; bytes: number; references: number }
/** Conservative retained-heap accounting: UTF16 strings + object/array slots/headers.
* Repeated references are deliberately charged repeatedly within each representation.
* No serialized response bodies are retained. This is a bound on charged memory,
* not a measurement of a particular JavaScript engine's allocator or temporary work.
*/
export function retainedBytes(value: unknown): number {
if (value === null || value === undefined) return 16
if (typeof value === 'string') return 64 + value.length * 4
if (typeof value !== 'object') return 16
if (Array.isArray(value)) return 128 + value.reduce((sum, item) => sum + 16 + retainedBytes(item), 0)
return 128 + Object.entries(value).reduce((sum, [key, item]) => sum + 32 + retainedBytes(key) + retainedBytes(item), 0)
}
function freeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
for (const child of Object.values(value)) freeze(child)
Object.freeze(value)
}
return value
}
export class PolicyCache {
private entries = new Map<string, Entry>()
private intern = new Map<string, Intern>()
private charged = 0
constructor(private maxEntries = 128, private maxBytes = 32 * 1024 * 1024) {}
get(agentId: string, key: string, now: number): EvaluatedPolicy | undefined {
const entry = this.entries.get(agentId)
if (!entry) return undefined
if (entry.key !== key || now >= entry.expiresAt) { this.remove(agentId); return undefined }
this.entries.delete(agentId); this.entries.set(agentId, entry)
return entry.policy
}
private remove(id: string) {
const entry = this.entries.get(id)
if (!entry) return
this.entries.delete(id); this.charged -= entry.bytes
const content = this.intern.get(entry.contentKey)!
if (--content.references === 0) { this.charged -= content.bytes; this.intern.delete(entry.contentKey) }
}
put(agentId: string, key: string, policy: EvaluatedPolicy, expiresAt: number): EvaluatedPolicy {
this.remove(agentId)
const { denyCidrs, allowCidrs, portRules, ...metadata } = policy
const bytes = retainedBytes({ agentId, key, metadata, expiresAt }) + 512
const content = { denyCidrs, allowCidrs, portRules }
const contentBytes = retainedBytes(content) + retainedBytes(policy.hash) + 512
const existing = this.intern.get(policy.hash)
if (bytes + contentBytes > this.maxBytes || this.maxEntries < 1) return policy
while (this.entries.size >= this.maxEntries || this.charged + bytes + (this.intern.has(policy.hash) ? 0 : contentBytes) > this.maxBytes) this.remove(this.entries.keys().next().value!)
// Eviction may have released the matching intern entry.
const intern = this.intern.get(policy.hash) ?? { content: freeze(existing?.content ?? content), bytes: contentBytes, references: 0 }
if (!this.intern.has(policy.hash)) { this.intern.set(policy.hash, intern); this.charged += contentBytes }
intern.references++
const stored = freeze({ ...metadata, ...intern.content })
this.entries.set(agentId, { key, policy: stored, contentKey: policy.hash, bytes, expiresAt })
this.charged += bytes
return stored
}
get stats() { return { entries: this.entries.size, contents: this.intern.size, bytes: this.charged, maxEntries: this.maxEntries, maxBytes: this.maxBytes } }
}
// Closing a DB and dropping its last reference releases all cache content.
const caches = new WeakMap<Db, PolicyCache>()
export function policyCacheFor(db: Db) {
let cache = caches.get(db)
if (!cache) { cache = new PolicyCache(); caches.set(db, cache) }
return cache
}
+115 -15
View File
@@ -5,8 +5,17 @@ import {
defaultActionFromLegacyMode,
legacyModeFromDefaultAction,
type DefaultAction,
cidrContains,
type ExplainMatch,
POLICY_BUDGET, POLICY_BUDGET_ERROR,
} from '@evofw/shared'
import { uniqCidrs } from '../uniq.js'
import { AppError } from '../../plugins/error-handler.js'
import { policyCacheFor } from './cache.js'
export function policyBudgetExceeded(reason: string): never {
throw new AppError(POLICY_BUDGET_ERROR, `Policy budget exceeded: ${reason}`, 422)
}
export const POLICY_APPLY_VERSION = 3 as const
@@ -80,9 +89,18 @@ function buildExpansionContext(
const hostnameRuleIds = [
...new Set(rules.filter((r) => r.hostname?.trim()).map((r) => r.id)),
]
let entriesByList: Map<string, string[]>, resolvedByRule: Map<string, string[]>
try {
entriesByList = repos.mapIpListEntriesByListIds(db, listIds, POLICY_BUDGET.rawMemberships)
const loaded = [...entriesByList.values()].reduce((n, cidrs) => n + cidrs.length, 0)
resolvedByRule = repos.mapResolvedCidrsByRuleIds(db, hostnameRuleIds, POLICY_BUDGET.rawMemberships - loaded)
} catch (error) {
if (error instanceof RangeError) policyBudgetExceeded('200000 raw memberships')
throw error
}
return {
entriesByList: repos.mapIpListEntriesByListIds(db, listIds),
resolvedByRule: repos.mapResolvedCidrsByRuleIds(db, hostnameRuleIds),
entriesByList,
resolvedByRule,
listNames: repos.mapIpListNames(db, listIds),
}
}
@@ -138,7 +156,7 @@ function expandPortSrcCidrs(
listId: string | null
},
): string[] {
if (row.srcKind === 'all') return ['0.0.0.0/0']
if (row.srcKind === 'all') return ['0.0.0.0/0', '::/0']
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
return [row.srcCidr.trim()]
}
@@ -154,6 +172,7 @@ function expandPortSrcCidrs(
function expandPortRules(
ctx: ExpansionContext,
rows: ReturnType<typeof repos.listEnabledAgentPortRules>,
budget: { raw: number; effective: number },
): EvaluatedPortRule[] {
const out: EvaluatedPortRule[] = []
for (const row of rows) {
@@ -169,6 +188,11 @@ function expandPortRules(
? ['tcp', 'udp']
: ['tcp']
for (const protocol of protocols) {
budget.raw += row.srcKind === 'list' ? (ctx.entriesByList.get(row.listId?.trim() ?? '')?.length ?? 0) : srcCidrs.length
budget.effective += srcCidrs.length
if (budget.raw > POLICY_BUDGET.rawMemberships) policyBudgetExceeded('200000 raw memberships')
if (budget.effective > POLICY_BUDGET.effectiveMemberships) policyBudgetExceeded('50000 effective memberships')
if (out.length >= POLICY_BUDGET.serializedRules) policyBudgetExceeded('1000 serialized port rules')
out.push({
id: row.id,
action,
@@ -183,7 +207,33 @@ function expandPortRules(
}
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
export function evaluateAgentPolicy(db: Db, agentId: string, now = new Date().toISOString()): EvaluatedPolicy {
const pin = repos.getPolicyPin(db, agentId)
if (pin) {
const agent = repos.getAgent(db, agentId)!
const syncIntervalSec = Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60
const cache = policyCacheFor(db)
const key = JSON.stringify(['pin', pin.snapshot_id, agent.policyGeneration, syncIntervalSec])
const hit = cache.get(agentId, key, Date.parse(now))
if (hit) return hit
const snapshot = repos.getPolicySnapshot(db, pin.snapshot_id)!
return cache.put(agentId, key, { ...JSON.parse(snapshot.payload_json), generation: agent.policyGeneration, syncIntervalSec }, Infinity)
}
// Expiry publication/bump must run before even consulting a cached revision.
repos.expireOverridesForAgent(db, agentId, now)
const agent = repos.getAgent(db, agentId)
if (!agent) throw new Error(`agent not found: ${agentId}`)
const key = JSON.stringify([agent.policyGeneration, agent.defaultAction, repos.getSetting(db, 'agent_sync_interval_sec')])
const cache = policyCacheFor(db)
const hit = cache.get(agentId, key, Date.parse(now))
if (hit) return hit
const policy = compileAgentPolicy(db, agentId, now)
const expires = repos.nextOverrideExpiry(db, agentId)
return cache.put(agentId, key, policy, expires ? Date.parse(expires) : Infinity)
}
export function compileAgentPolicy(db: Db, agentId: string, now: string): EvaluatedPolicy {
repos.expireOverridesForAgent(db, agentId, now)
const agent = repos.getAgent(db, agentId)
if (!agent) {
throw new Error(`agent not found: ${agentId}`)
@@ -193,12 +243,20 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
.listSetsForAgent(db, agentId)
.filter((s) => s.enabled === 1)
const ordered = repos.listPolicyRulesForAgent(db, agentId)
const overrides = repos.listOverrides(db, agentId)
const overrides = repos.listActiveOverrides(db, agentId, now)
const portRuleRows = repos.listEnabledAgentPortRules(db, agentId)
if (portRuleRows.reduce((n, row) => n + (row.protocol === 'both' ? 2 : 1), 0) > POLICY_BUDGET.serializedRules) policyBudgetExceeded('1000 serialized port rules')
if (ordered.length + overrides.length > POLICY_BUDGET.rawMemberships) policyBudgetExceeded('policy metadata cardinality')
const ctx = buildExpansionContext(db, ordered, portRuleRows)
const deny: string[] = []
const allow: string[] = []
const deny = new Set<string>()
const allow = new Set<string>()
const budget = { raw: 0, effective: 0 }
const addSource = (target: Set<string>, cidrs: string[]) => {
budget.raw += cidrs.length
if (budget.raw > POLICY_BUDGET.rawMemberships) policyBudgetExceeded('200000 raw memberships')
for (const cidr of cidrs) target.add(cidr)
}
const chain: PolicyChainStep[] = []
let rulesDeny = 0
let rulesAllow = 0
@@ -207,10 +265,10 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const cidrs = expandRule(ctx, rule)
const action = rule.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') {
deny.push(...cidrs)
addSource(deny, cidrs)
rulesDeny += 1
} else {
allow.push(...cidrs)
addSource(allow, cidrs)
rulesAllow += 1
}
const src = sourceMeta(ctx, rule)
@@ -229,8 +287,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
for (const o of overrides) {
const action = o.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') deny.push(o.cidr)
else allow.push(o.cidr)
addSource(action === 'deny' ? deny : allow, [o.cidr])
chain.push({
setId: null,
setName: null,
@@ -242,24 +299,26 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
})
}
const denyCidrs = uniqCidrs(deny)
const denyCidrs = uniqCidrs([...deny])
const denySet = new Set(denyCidrs)
const allowRaw = uniqCidrs(allow)
const allowRaw = uniqCidrs([...allow])
const allowCidrs = allowRaw.filter((c) => !denySet.has(c))
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction)
const portRules = expandPortRules(ctx, portRuleRows)
budget.effective = denyCidrs.length + allowCidrs.length
if (budget.effective > POLICY_BUDGET.effectiveMemberships) policyBudgetExceeded('50000 effective memberships')
const portRules = expandPortRules(ctx, portRuleRows, budget)
const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION,
generation: agent.policyGeneration,
defaultAction,
denyCidrs,
allowCidrs,
portRules,
})
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
if (Buffer.byteLength(payload) > POLICY_BUDGET.jsonBytes) policyBudgetExceeded('8MiB policy JSON')
const syncIntervalSec =
Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60
@@ -293,3 +352,44 @@ export function truncateCidrs(cidrs: string[], limit: number): string[] {
if (limit <= 0) return []
return cidrs.slice(0, limit)
}
/** Bounded source witnesses use the compiler's own expansion and active overrides. */
export function policySourceWitnesses(db: Db, agentId: string, ip: string, now = new Date().toISOString()) {
if (repos.getPolicyPin(db, agentId)) {
const policy = evaluateAgentPolicy(db, agentId, now)
const matches: ExplainMatch[] = []
for (const [stage, cidrs] of [['deny', policy.denyCidrs], ['allow', policy.allowCidrs]] as const) {
const cidr = cidrs.find((network) => cidrContains(ip, network))
if (cidr) matches.push({ rule_id: null, set_id: null, list_id: null, override_id: null, cidr,
label: 'Frozen policy snapshot; original source attribution unavailable', stage })
}
return { matches, total: matches.length, deny: matches.find((match) => match.stage === 'deny') ?? null, allow: matches.find((match) => match.stage === 'allow') ?? null }
}
const rules = repos.listPolicyRulesForAgent(db, agentId)
const ctx = buildExpansionContext(db, rules, [])
const sets = new Map(repos.listSetsForAgent(db, agentId).map((set) => [set.setId, set.name]))
const matches: ExplainMatch[] = []
let total = 0
let deny: ExplainMatch | null = null, allow: ExplainMatch | null = null
const matchCache = new Map<string, string | null>()
const add = (match: ExplainMatch) => {
total++
if (matches.length < 100) matches.push(match)
if (match.stage === 'deny' && !deny) deny = match
if (match.stage === 'allow' && !allow) allow = match
}
for (const rule of rules) {
const key = rule.listId ? `list:${rule.listId}` : rule.hostname ? `host:${rule.id}` : `cidr:${rule.cidr}`
if (!matchCache.has(key)) matchCache.set(key, expandRule(ctx, rule).find((network) => cidrContains(ip, network)) ?? null)
const cidr = matchCache.get(key)
if (!cidr) continue
const source = sourceMeta(ctx, rule)
add({ rule_id: rule.id, set_id: rule.setId, list_id: rule.listId, override_id: null, cidr,
label: `${sets.get(rule.setId) ?? rule.setId}: ${source.label}`, stage: rule.action === 'deny' ? 'deny' : 'allow' })
}
for (const override of repos.listActiveOverrides(db, agentId, now)) if (cidrContains(ip, override.cidr)) {
add({ rule_id: null, set_id: null, list_id: null, override_id: override.id, cidr: override.cidr,
label: override.reason || override.comment || override.cidr, stage: override.action === 'deny' ? 'deny' : 'allow' })
}
return { matches, total, deny, allow }
}
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { explainQuerySchema, explainResponseSchema } from '@evofw/shared'
import { explainAgentPolicy } from './explain.js'
import { evaluateAgentPolicy } from './evaluate.js'
import { readFileSync } from 'node:fs'
const opened: ReturnType<typeof createMemoryDb>[] = []
afterEach(() => { for (const mem of opened.splice(0)) mem.sqlite.close() })
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
repos.insertAgent(mem.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' })
repos.ensureSharedSetAssigned(mem.db, 'a')
return mem
}
const now = '2026-01-01T00:00:00.000Z'
const later = '2026-01-01T00:02:00.000Z'
describe('policy explain and override expiry', () => {
it('follows the actual iptables compiler INPUT-only chain without nft conntrack bypass', () => {
const script = readFileSync(new URL('../../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const compiler = script.slice(script.indexOf('candidate = ["*filter",'), script.indexOf('rollback = ["*filter",'))
expect(compiler).toContain('--match-set evofw_deny_v{family} src -j DROP')
expect(compiler).toContain('--match-set evofw_allow_v{family} src -j ACCEPT')
expect(compiler).toContain('-I INPUT 1 -j EVOFW_INPUT')
expect(compiler).not.toMatch(/FORWARD|ESTABLISHED|RELATED/)
const { db } = memory()
repos.updateAgent(db, 'a', { lastApplyKernelMethod: 'ipset' })
repos.insertPolicyRule(db, { id: 'deny', setId: 'set-shared-default', priority: 10, action: 'deny', cidr: '10.0.0.0/8' })
repos.insertPolicyRule(db, { id: 'allow', setId: 'set-shared-default', priority: 20, action: 'allow', cidr: '192.0.2.0/24' })
for (const state of ['new', 'established', 'related']) {
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '10.0.0.1', connection_state: state }), now).desired).toMatchObject({ decision: 'drop', stage: 'deny' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '10.0.0.1', chain: 'forward', connection_state: state }), now).desired).toMatchObject({ decision: null, stage: 'external' })
}
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '192.0.2.1' }), now).desired).toMatchObject({ decision: 'accept', stage: 'allow' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8' }), now).desired).toMatchObject({ decision: null, stage: 'external' })
repos.updateAgent(db, 'a', { defaultAction: 'drop' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8' }), now).desired).toMatchObject({ decision: 'drop', stage: 'default' })
})
it('bounds active overrides and leaves target assignments/defaults/overrides intact when clone exceeds the limit', () => {
const { db, sqlite } = memory()
repos.insertAgent(db, { id: 'b', name: 'b', tokenPrefix: 'b', tokenHash: 'b', status: 'approved', defaultAction: 'drop' })
repos.insertPolicySet(db, { id: 'keep', name: 'Keep', enabled: 1 })
repos.setAgentPolicySets(db, 'b', ['keep'])
repos.insertOverride(db, { id: 'target', agentId: 'b', cidr: '8.8.8.8/32', action: 'allow' })
const statement = sqlite.prepare("INSERT INTO ip_overrides(id,agent_id,cidr,action) VALUES(?,'a',?,'deny')")
sqlite.transaction(() => { for (let i = 0; i < 1000; i++) statement.run(`o-${i}`, `10.${i >> 8}.${i & 255}.1/32`) })()
expect(() => repos.insertOverride(db, { id: 'overflow', agentId: 'a', cidr: '9.9.9.9/32', action: 'deny' })).toThrow(/1000/)
statement.run('legacy-over-limit', '9.9.9.9/32')
const beforeAgent = repos.getAgent(db, 'b'), beforeSets = repos.listSetsForAgent(db, 'b'), beforeOverrides = repos.listOverrides(db, 'b')
expect(() => repos.cloneRulesFrom(db, 'a', 'b', true)).toThrow(/1000/)
expect(repos.getAgent(db, 'b')).toEqual(beforeAgent)
expect(repos.listSetsForAgent(db, 'b')).toEqual(beforeSets)
expect(repos.listOverrides(db, 'b')).toEqual(beforeOverrides)
})
it('keeps at most100 recent expired overrides/agent and7days after one expiry generation bump', () => {
const { db, sqlite } = memory()
const statement = sqlite.prepare("INSERT INTO ip_overrides(id,agent_id,cidr,action,expires_at,reason) VALUES(?,'a',?,'deny',?,'Archived reason')")
sqlite.transaction(() => {
for (let i = 0; i < 150; i++) statement.run(`old-${i}`, `10.0.${i}.1/32`, '2026-01-01T00:01:00.000Z')
statement.run('too-old', '9.9.9.9/32', '2025-12-20T00:00:00.000Z')
})()
const generation = repos.getAgent(db, 'a')!.policyGeneration
expect(repos.expireOverridesForAgent(db, 'a', later)).toBe(151)
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(generation + 1)
expect(repos.listOverrides(db, 'a')).toHaveLength(100)
expect(repos.listOverrides(db, 'a').some((row) => row.id === 'too-old')).toBe(false)
expect(repos.listOverrides(db, 'a')[0]!.reason).toBe('Archived reason')
expect(repos.expireOverridesForAgent(db, 'a', later)).toBe(0)
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(generation + 1)
})
it('follows emitted nft precedence deny > close > open > implicit close > allow > default', () => {
const { db } = memory()
repos.insertPolicyRule(db, { id: 'deny', setId: 'set-shared-default', priority: 10, action: 'deny', cidr: '10.0.0.0/8' })
repos.insertPolicyRule(db, { id: 'allow', setId: 'set-shared-default', priority: 20, action: 'allow', cidr: '0.0.0.0/0' })
for (const [id, action, srcCidr, priority] of [['open', 'open', '192.0.2.0/24', 1], ['close', 'close', '192.0.2.1/32', 999]] as const) repos.insertAgentPortRule(db, { id, agentId: 'a', action, protocol: 'tcp', portStart: 22, portEnd: 22, srcKind: 'cidr', srcCidr, priority })
const cases = [
['10.0.0.1', 22, 'drop', 'deny'], ['192.0.2.1', 22, 'drop', 'port_close'],
['192.0.2.2', 22, 'accept', 'port_open'], ['203.0.113.1', 22, 'drop', 'port_implicit_close'],
['203.0.113.1', 443, 'accept', 'allow'], ['2001:db8::1', 22, 'drop', 'port_implicit_close'],
['2001:db8::1', 443, 'accept', 'default'],
] as const
for (const [ip, port, decision, stage] of cases) {
const result = explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip, port, protocol: 'tcp' }), now)
expect(result.desired).toMatchObject({ decision, stage })
expect(explainResponseSchema.safeParse(result).success).toBe(true)
expect(result.limits).toContain('external_firewall_may_override_accept')
expect(result.applied.same_as_desired).toBeNull()
}
const withoutTransport = explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '203.0.113.1' }), now)
expect(withoutTransport.desired).toMatchObject({ decision: null, stage: 'transport_unknown' })
const established = explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '10.0.0.1', protocol: 'tcp', port: 22, connection_state: 'established' }), now)
expect(established.desired).toMatchObject({ decision: 'accept', stage: 'stateful' })
})
it('scopes forward port ACL to DNAT and distinguishes RouterOS external and unsupported cases', () => {
const { db } = memory()
repos.insertAgentPortRule(db, { id: 'close', agentId: 'a', action: 'close', protocol: 'tcp', portStart: 22, portEnd: 22, srcKind: 'all' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8', port: 22, protocol: 'tcp', chain: 'forward' }), now).desired).toMatchObject({ decision: 'accept', stage: 'default' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8', port: 22, protocol: 'tcp', chain: 'forward', dnat: 'true' }), now).desired).toMatchObject({ decision: 'drop', stage: 'port_close' })
repos.updateAgent(db, 'a', { platform: 'mikrotik', defaultAction: 'drop' })
repos.deleteAgentPortRule(db, 'close')
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8' }), now).desired).toMatchObject({ decision: null, stage: 'external' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8', chain: 'forward' }), now).desired).toMatchObject({ decision: 'drop', stage: 'default' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '2001:db8::1', chain: 'forward' }), now).desired).toMatchObject({ decision: null, stage: 'unsupported' })
repos.updateAgent(db, 'a', { telemetryVersion: 2, capabilitiesJson: JSON.stringify(['policy-ipv6']), clientVersion: 'rsc/9' })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '2001:db8::1', chain: 'forward' }), now).desired).toMatchObject({ decision: 'drop', stage: 'default' })
})
it('marks the entire desired candidate unsupported when reported runtime cannot apply its ACL/IPv6 features', () => {
const { db } = memory()
repos.insertPolicyRule(db, { id: 'deny', setId: 'set-shared-default', priority: 10, action: 'deny', cidr: '10.0.0.0/8' })
repos.insertAgentPortRule(db, { id: 'open', agentId: 'a', action: 'open', protocol: 'tcp', portStart: 22, portEnd: 22, srcKind: 'all' })
repos.updateAgent(db, 'a', { lastApplyKernelMethod: 'ipset', telemetryVersion: 2, capabilitiesJson: JSON.stringify(['policy_ipv4']) })
const rejected = explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '10.0.0.1', port: 22, protocol: 'tcp' }), now)
expect(rejected.desired).toMatchObject({ decision: null, stage: 'unsupported' })
expect(rejected.limits).toContain('port_acl_requires_nft_backend')
repos.updateAgent(db, 'a', { lastApplyKernelMethod: 'nft' })
repos.insertPolicyRule(db, { id: 'v6', setId: 'set-shared-default', priority: 20, action: 'deny', cidr: '2001:db8::/32' })
repos.bumpAgentGeneration(db, 'a')
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '10.0.0.1' }), now).desired).toMatchObject({ decision: null, stage: 'unsupported' })
repos.updateAgent(db, 'a', { capabilitiesJson: JSON.stringify(['policy-ipv6', 'policy-port-acl-ipv6']) })
expect(explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '2001:db8::1' }), now).desired).toMatchObject({ decision: 'drop', stage: 'deny' })
})
it('uses compiler list/hostname/override witnesses while preserving deny-wins semantics', () => {
const { db } = memory()
repos.insertIpList(db, { id: 'l', name: 'Source list', type: 'static' }); repos.replaceIpListEntries(db, 'l', ['192.0.2.0/24'])
repos.insertPolicyRule(db, { id: 'r', setId: 'set-shared-default', priority: 10, action: 'deny', listId: 'l' })
repos.insertOverride(db, { id: 'o', agentId: 'a', cidr: '192.0.2.1/32', action: 'allow', reason: 'Temporary diagnosis', expiresAt: later, createdByUserId: 'operator' })
const result = explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '192.0.2.1' }), now)
expect(result.desired.match).toMatchObject({ rule_id: 'r', list_id: 'l', cidr: '192.0.2.0/24' })
expect(result.matches.some((match) => match.override_id === 'o')).toBe(true)
expect(result.desired.decision).toBe('drop')
expect(result.limits).toContain('source_attribution_is_logical_witness')
})
it('expires overrides before computing hash, increments once, preserves author/reason and never resurrects on a backward clock', () => {
const { db } = memory()
repos.insertOverride(db, { id: 'o', agentId: 'a', cidr: '8.8.8.8/32', action: 'deny', reason: 'Investigation', createdByUserId: 'operator', createdByName: 'Operator', expiresAt: later })
const before = evaluateAgentPolicy(db, 'a', now)
expect(before.denyCidrs).toEqual(['8.8.8.8/32'])
const expired = evaluateAgentPolicy(db, 'a', later)
expect(expired.denyCidrs).toEqual([])
expect(expired.hash).not.toBe(before.hash)
expect(expired.generation).toBe(before.generation + 1)
expect(evaluateAgentPolicy(db, 'a', later).generation).toBe(expired.generation)
expect(evaluateAgentPolicy(db, 'a', now).denyCidrs).toEqual([])
expect(repos.listOverrides(db, 'a')[0]).toMatchObject({ reason: 'Investigation', createdByUserId: 'operator', expiredAt: later })
})
it('bounds witnesses and reports actual desired/applied differences without claiming external rules are complete', () => {
const { db } = memory()
for (let i = 0; i < 110; i++) repos.insertPolicyRule(db, { id: `r-${i}`, setId: 'set-shared-default', priority: i + 1, action: 'allow', cidr: '0.0.0.0/0' })
const desired = evaluateAgentPolicy(db, 'a', now)
repos.updateAgent(db, 'a', { appliedHash: desired.hash, appliedRevision: desired.generation })
const result = explainAgentPolicy(db, 'a', explainQuerySchema.parse({ ip: '8.8.8.8' }), now)
expect(result.matches).toHaveLength(100)
expect(result.matches_total).toBe(110)
expect(result.truncated).toBe(true)
expect(result.applied.same_as_desired).toBe(true)
expect(result.external_firewall.complete).toBe(false)
})
})
+92
View File
@@ -0,0 +1,92 @@
import { repos, type Db } from '@evofw/db'
import { cidrContains, type ExplainMatch, type ExplainQuery, type ExplainResponse } from '@evofw/shared'
import { evaluateAgentPolicy, policySourceWitnesses } from './evaluate.js'
import { mapAgentDiagnostics } from '../row-mappers.js'
/** Explain desired new-flow rules; never equate a configured accept with observed delivery. */
export function explainAgentPolicy(db: Db, agentId: string, query: ExplainQuery, now = new Date().toISOString()): ExplainResponse {
const policy = evaluateAgentPolicy(db, agentId, now)
const agent = repos.getAgent(db, agentId)!
const sources = policySourceWitnesses(db, agentId, query.ip, now)
const matches = [...sources.matches]
let matchesTotal = sources.total
const add = (match: ExplainMatch) => { matchesTotal++; if (matches.length < 100) matches.push(match); return match }
const limits = ['desired_policy_not_kernel_observation', 'external_firewall_may_override_accept', 'non_loopback_ingress_assumed']
const diagnostics = mapAgentDiagnostics({ ...agent, desiredHash: policy.hash, desiredHashGeneration: policy.generation })
const needsIpv6 = policy.denyCidrs.some((cidr) => cidr.includes(':')) || policy.allowCidrs.some((cidr) => cidr.includes(':')) || policy.portRules.some((rule) => rule.srcCidrs.some((cidr) => cidr.includes(':')))
const knownCapabilities = agent.telemetryVersion !== null
const ipsetBackend = agent.platform === 'linux' && /ipset|iptables/.test(agent.lastApplyKernelMethod ?? '')
const ipsetAclUnsupported = ipsetBackend && policy.portRules.length > 0
const mikrotikAclUnsupported = agent.platform === 'mikrotik' && policy.portRules.length > 0
const ipv6Unsupported = (needsIpv6 || query.ip.includes(':')) && (knownCapabilities ? !diagnostics.capabilities.includes('policy-ipv6') : agent.platform === 'mikrotik')
const ipv6AclUnsupported = agent.platform === 'linux' && knownCapabilities && policy.portRules.some((rule) => rule.srcCidrs.some((cidr) => cidr.includes(':'))) && !diagnostics.capabilities.includes('policy-port-acl-ipv6')
let decision: 'accept' | 'drop' | null = null
let stage: ExplainResponse['desired']['stage'] = 'default'
let match: ExplainMatch | null = null
const deny = policy.denyCidrs.find((network) => cidrContains(query.ip, network))
const allow = policy.allowCidrs.find((network) => cidrContains(query.ip, network))
const portApplicable = agent.platform === 'linux' && query.port !== undefined && (query.chain === 'input' || query.dnat)
const portCandidates = portApplicable ? policy.portRules.filter((rule) => rule.protocol === query.protocol && query.port! >= rule.portStart && query.port! <= rule.portEnd) : []
const portMatches = new Map<string, ExplainMatch>()
for (const rule of portCandidates) {
const cidr = rule.srcCidrs.find((network) => cidrContains(query.ip, network))
if (!cidr) continue
const item = add({ rule_id: rule.id, set_id: null, list_id: null, override_id: null, cidr,
label: `${rule.protocol}/${rule.portStart === rule.portEnd ? rule.portStart : `${rule.portStart}-${rule.portEnd}`}`,
stage: rule.action === 'close' ? 'port_close' : 'port_open' })
portMatches.set(`${rule.id}:${rule.protocol}`, item)
}
const close = portCandidates.find((rule) => rule.action === 'close' && portMatches.has(`${rule.id}:${rule.protocol}`))
const open = portCandidates.find((rule) => rule.action === 'open' && portMatches.has(`${rule.id}:${rule.protocol}`))
const implicit = portCandidates.find((rule) => rule.action === 'open')
if (ipsetAclUnsupported || mikrotikAclUnsupported || ipv6Unsupported || ipv6AclUnsupported) {
stage = 'unsupported'
if (ipsetAclUnsupported) limits.push('port_acl_requires_nft_backend')
if (mikrotikAclUnsupported) limits.push('mikrotik_port_acl_not_supported')
if (ipv6Unsupported) limits.push('ipv6_policy_capability_not_reported')
if (ipv6AclUnsupported) limits.push('ipv6_port_acl_capability_not_reported')
} else if (ipsetBackend && query.chain === 'forward') {
stage = 'external'; limits.push('ipset_forward_chain_not_managed')
} else if (agent.platform === 'linux' && !ipsetBackend && query.connection_state !== 'new') {
stage = 'stateful'; decision = 'accept'; limits.push('established_related_precedes_owned_policy')
} else if (deny) {
stage = 'deny'; decision = 'drop'; match = sources.deny
} else if (close) {
stage = 'port_close'; decision = 'drop'; match = portMatches.get(`${close.id}:${close.protocol}`)!
} else if (open) {
stage = 'port_open'; decision = 'accept'; match = portMatches.get(`${open.id}:${open.protocol}`)!
} else if (implicit) {
stage = 'port_implicit_close'; decision = 'drop'
match = add({ rule_id: implicit.id, set_id: null, list_id: null, override_id: null, cidr: null,
label: `${implicit.protocol}/${implicit.portStart}-${implicit.portEnd}: unmatched source`, stage })
} else if (agent.platform === 'linux' && query.port === undefined && policy.portRules.length && (query.chain === 'input' || query.dnat)) {
stage = 'transport_unknown'; limits.push('port_protocol_required_for_l4_decision')
} else if (agent.platform === 'mikrotik' && query.chain === 'input') {
stage = 'external'; limits.push('mikrotik_input_allow_and_default_are_external')
} else if (allow) {
stage = 'allow'; decision = 'accept'; match = sources.allow
} else if ((agent.platform === 'mikrotik' || ipsetBackend) && policy.defaultAction === 'accept') {
stage = 'external'; limits.push(ipsetBackend ? 'ipset_accept_default_is_external_fallthrough' : 'mikrotik_accept_default_is_external_fallthrough')
} else {
stage = 'default'; decision = policy.defaultAction
}
if (query.chain === 'forward') limits.push(query.dnat ? 'forward_port_acl_dnat_scope' : 'forward_port_acl_skipped_without_dnat')
if ((needsIpv6 || query.ip.includes(':')) && !knownCapabilities && agent.platform === 'linux') limits.push('ipv6_runtime_capability_unknown')
if (match && (stage === 'deny' || stage === 'allow')) limits.push('source_attribution_is_logical_witness')
const snapshot = repos.getHostFirewallSnapshot(db, agentId)
let externalRules = 0
if (snapshot) {
try {
const payload = JSON.parse(snapshot.payloadJson) as { rules?: { ownership?: string }[] }
externalRules = payload.rules?.filter((rule) => rule.ownership === 'foreign').length ?? 0
} catch { /* unavailable snapshots never become proof of external absence */ }
}
return {
agent_id: agentId,
query: { ip: query.ip, port: query.port ?? null, protocol: query.protocol ?? null, chain: query.chain, dnat: query.dnat, connection_state: query.connection_state },
desired: { revision: policy.generation, hash: policy.hash, decision, stage, match },
applied: { revision: agent.appliedRevision, hash: agent.appliedHash, same_as_desired: agent.appliedHash ? agent.appliedHash === policy.hash && agent.appliedRevision === policy.generation : null, status: diagnostics.status },
matches, matches_total: matchesTotal, truncated: matchesTotal > matches.length, limits,
external_firewall: { observed_at: snapshot?.collectedAt ?? null, rule_count: externalRules, complete: false },
}
}
@@ -1,62 +1,82 @@
import { describe, expect, it } from 'vitest'
import {
POLICY_APPLY_VERSION,
type EvaluatedPolicy,
} from './evaluate.js'
import { renderMikrotikPolicyRsc } from './mikrotik-rsc.js'
import { POLICY_APPLY_VERSION, type EvaluatedPolicy } from './evaluate.js'
import { isIpv4Cidr, renderMikrotikPolicyRsc } from './mikrotik-rsc.js'
function basePolicy(
patch: Partial<EvaluatedPolicy> = {},
): EvaluatedPolicy {
return {
generation: 3,
hash: 'sha256:abc',
applyVersion: POLICY_APPLY_VERSION,
defaultAction: 'accept',
policyMode: 'blacklist',
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
portRules: [],
conflictsDropped: 0,
syncIntervalSec: 60,
chain: [],
summary: {
sets: 1,
rulesDeny: 1,
rulesAllow: 1,
cidrsDeny: 2,
cidrsAllow: 1,
overrides: 0,
conflictsDropped: 0,
portRules: 0,
},
...patch,
}
function policy(patch: Partial<EvaluatedPolicy> = {}): EvaluatedPolicy {
return { generation: 3, hash: 'sha256:' + 'a'.repeat(64), applyVersion: POLICY_APPLY_VERSION, defaultAction: 'accept', policyMode: 'blacklist', denyCidrs: ['1.2.3.0/24', '2001:db8::/32'], allowCidrs: ['8.8.8.8/32', '2001:db8:1::/64'], portRules: [], conflictsDropped: 0, syncIntervalSec: 60, chain: [], summary: { sets: 1, rulesDeny: 1, rulesAllow: 1, cidrsDeny: 2, cidrsAllow: 2, overrides: 0, conflictsDropped: 0, portRules: 0 }, ...patch }
}
describe('renderMikrotikPolicyRsc', () => {
it('renders accept default: lists + default-drop disabled', () => {
const rsc = renderMikrotikPolicyRsc(basePolicy())
expect(rsc).toContain(
`# evofw hash=sha256:abc default_action=accept apply_version=${POLICY_APPLY_VERSION} gen=3`,
)
expect(rsc).toContain('list=EVOFW_DENY')
expect(rsc).toContain('list=EVOFW_ALLOW')
expect(rsc).toContain('address=1.2.3.0/24')
expect(rsc).not.toContain('2001:db8')
expect(rsc).toContain(
'evofw-default-drop-forward] disabled=yes',
)
expect(rsc).toContain('evofw-deny-drop-input] disabled=no')
describe('RouterOS dual-stack canonical compiler (rendered contract, no device runtime)', () => {
it('stages both family banks and complete disabled rule candidates before activating either family', () => {
const rsc = renderMikrotikPolicyRsc(policy())
expect(rsc).toContain('/ip firewall address-list add list=$deny4 address=1.2.3.0/24')
expect(rsc).toContain('/ipv6 firewall address-list add list=$deny6 address=2001:db8::/32')
expect(rsc).toContain('/ipv6 firewall address-list add list=$allow6 address=2001:db8:1::/64')
const firstActivation = rsc.indexOf('/ip firewall filter set [find comment=("evofw-stage-"')
expect(rsc.lastIndexOf(':set new6')).toBeLessThan(firstActivation)
expect(rsc.indexOf('address=2001:db8:1::/64')).toBeLessThan(firstActivation)
expect(rsc).toContain('filter add chain=input action=drop src-address-list=$deny6 disabled=yes')
expect(rsc).not.toContain('filter add chain=input action=accept')
expect(rsc).toContain('"-default-drop-forward")] disabled=yes')
})
it('renders drop default: default-drop enabled', () => {
const rsc = renderMikrotikPolicyRsc(
basePolicy({ defaultAction: 'drop', policyMode: 'whitelist' }),
)
expect(rsc).toContain('default_action=drop')
expect(rsc).toContain(
'evofw-default-drop-forward] disabled=no',
)
it('keeps original matchers and order intact, with both-family compensating rollback and disabled-state restoration', () => {
const rsc = renderMikrotikPolicyRsc(policy({ defaultAction: 'drop' }))
for (const [family, path] of [[4, '/ip firewall'], [6, '/ipv6 firewall']] as const) {
expect(rsc).toContain(`${path} filter print as-value`)
expect(rsc).toContain(`:set old${family} ($old${family},{$row})`)
expect(rsc).toContain(`${path} filter set ($row->".id") disabled=($row->"disabled") comment=($row->"comment")`)
expect(rsc).toContain(`${path} filter remove $new${family}`)
expect(rsc).toContain(`:foreach row in=$old${family}`)
}
expect(rsc).not.toContain('filter move ($row->".id")')
expect(rsc).not.toContain('filter remove [find comment=evofw-')
expect(rsc).toContain('CRITICAL: both-family rollback failed')
expect(rsc).toContain('"-default-drop-forward")] disabled=no')
})
it('supports missing and tampered owned rules through canonical replacement, bounds retired cleanup, and preserves foreign rules', () => {
const rsc = renderMikrotikPolicyRsc(policy())
const beforeStage = rsc.slice(0, rsc.indexOf('# Stage IPv4'))
expect(beforeStage).not.toContain('get [find comment=evofw-deny-drop-input]')
expect(beforeStage).toContain('^evofw-(deny-(hit|drop)-(input|forward)|allow-accept-forward|default-drop-forward)$')
expect(rsc).toContain('active retired rule needs manual recovery')
expect(rsc).toContain('filter remove $id')
expect(rsc).toContain('^EVOFW_(DENY|ALLOW)_')
expect(rsc).toContain('src-address-list=$listName]] = 0')
expect(rsc).toContain('unexpected owned matcher after repair')
expect(rsc).toContain('owned filter order drift after repair')
expect(rsc).not.toContain('filter remove [find]')
})
it('probes IPv6 before any mutation and reports unsupported when required; IPv4-only accept remains usable', () => {
const dual = renderMikrotikPolicyRsc(policy())
expect(dual.indexOf('IPv6 enforcement unavailable')).toBeLessThan(dual.indexOf('filter remove $id'))
expect(dual).toContain(':set EvofwApplyErrorCode "UNSUPPORTED_CAPABILITY"')
const ipv4 = renderMikrotikPolicyRsc(policy({ denyCidrs: ['192.0.2.1/32'], allowCidrs: [] }))
expect(ipv4).not.toContain(':error "IPv6 enforcement unavailable"')
expect(ipv4).toContain(':if ($v6Enabled) do={')
})
it('publishes the rendered hash/revision only after both-family verification succeeds', () => {
const rsc = renderMikrotikPolicyRsc(policy())
const hash = rsc.indexOf(':set EvofwRenderedHash "sha256:')
expect(hash).toBeGreaterThan(rsc.lastIndexOf('owned filter order drift after repair'))
expect(hash).toBeGreaterThan(rsc.lastIndexOf('both-family rollback completed'))
expect(rsc).toContain(':set EvofwRenderedRevision 3')
expect(rsc).toContain(':set EvofwRenderedIpv6 $v6Enabled')
})
it.each(['10.0.0.1; /system reboot', '2001:db8::/129', '10.0.0.0/33', 'fe80::1%eth0', '1.2.3.4/1/2'])('rejects invalid or scoped policy addresses: %s', (cidr) => {
expect(() => renderMikrotikPolicyRsc(policy({ denyCidrs: [cidr] }))).toThrow()
})
it('keeps the IPv4 classification helper strict', () => {
expect(isIpv4Cidr('192.0.2.1/32')).toBe(true)
expect(isIpv4Cidr('2001:db8::/64')).toBe(false)
expect(isIpv4Cidr('999.1.1.1')).toBe(false)
})
it('rejects unsupported Port ACL before generating a partially enforced script', () => {
expect(() => renderMikrotikPolicyRsc(policy({ portRules: [{ id: 'x', action: 'open', protocol: 'tcp', portStart: 443, portEnd: 443, srcCidrs: ['::/0'] }] }))).toThrow('Port ACL is unsupported')
})
it('counts canonical unique memberships before any adjacent or overlapping kernel compression', () => {
const entries = Array.from({ length: 50000 }, (_, i) => `10.${i >>> 16}.${(i >>> 8) & 255}.${i & 255}/32`)
expect(() => renderMikrotikPolicyRsc(policy({ denyCidrs: [...entries, '10.0.0.0/8'], allowCidrs: [] }))).toThrowError(expect.objectContaining({ code: 'POLICY_BUDGET_EXCEEDED' }))
const rsc = renderMikrotikPolicyRsc(policy({ denyCidrs: Array(100000).fill('192.0.2.129/24'), allowCidrs: [] }))
expect(rsc.match(/address=192\.0\.2\.0\/24/g)).toHaveLength(1)
})
})
+159 -41
View File
@@ -1,59 +1,177 @@
import { isIP } from 'node:net'
import { canonicalCidr } from '@evofw/shared'
import type { EvaluatedPolicy } from './evaluate.js'
/** Skip IPv6 (contains ':') — same as Linux agent. */
type Family = 4 | 6
const OWNED = '^evofw-(deny-(hit|drop)-(input|forward)|allow-accept-forward|default-drop-forward)$'
const SPECS = [
['deny-drop-input', 'input', 'drop', 'deny'],
['deny-drop-forward', 'forward', 'drop', 'deny'],
['allow-accept-forward', 'forward', 'accept', 'allow'],
['default-drop-forward', 'forward', 'drop', null],
] as const
export function isIpv4Cidr(cidr: string): boolean {
const t = cidr.trim()
return t.length > 0 && !t.includes(':')
try { return addressFamily(cidr) === 4 } catch { return false }
}
function escAddress(cidr: string): string {
const t = cidr.trim()
if (/^[0-9./-]+$/.test(t)) return t
return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
function addressFamily(cidr: string): Family {
const [address, prefix, ...rest] = cidr.trim().split('/')
const family = isIP(address)
if (address.includes('%') || !family || rest.length || (prefix !== undefined && (!/^\d{1,3}$/.test(prefix) || Number(prefix) > (family === 4 ? 32 : 128)))) throw new Error('Invalid CIDR in MikroTik policy')
return family as Family
}
/**
* RouterOS 7.x script: rebuild EVOFW_* address-lists.
* Unified chain: deny drop → allow accept → default (accept|drop).
* Expects permanent filter rules with comments:
* evofw-deny-drop-input / evofw-deny-drop-forward (always on)
* evofw-allow-accept-forward (always on for forward path)
* evofw-default-drop-forward (enabled when default_action=drop)
*/
export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
const defaultDrop = policy.defaultAction === 'drop'
const defaultDropDisabled = defaultDrop ? 'no' : 'yes'
function familyPath(family: Family) { return family === 4 ? '/ip firewall' : '/ipv6 firewall' }
const lines: string[] = [
`# evofw hash=${policy.hash} default_action=${policy.defaultAction} apply_version=${policy.applyVersion} gen=${policy.generation}`,
'/ip firewall address-list remove [find list=EVOFW_DENY]',
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
/** Build canonical owned rules alongside intact originals. Rollback restores only
* fields changed on original rules (comment/disabled); their matchers and order
* are preserved because originals are never reconstructed or moved. */
function stageFamily(policy: EvaluatedPolicy, family: Family): string[] {
const path = familyPath(family)
const lines = [`# Stage IPv${family} banks; old referenced lists stay intact.`]
for (const [kind, cidrs] of [['deny', policy.denyCidrs], ['allow', policy.allowCidrs]] as const) {
for (const cidr of cidrs) if (addressFamily(cidr) === family) lines.push(`${path} address-list add list=$${kind}${family} address=${cidr.trim()} comment=evofw`)
}
// Add in reverse order and move each disabled rule to the front. Per-chain
// ordering becomes deny -> allow -> default while foreign rules stay intact.
for (const [name, chain, action, kind] of [...SPECS].reverse()) {
const comment = `("evofw-stage-" . $tx . "-${name}")`
const source = kind ? ` src-address-list=$${kind}${family}` : ''
lines.push(
`${path} filter add chain=${chain} action=${action}${source} disabled=yes comment=${comment}`,
`:local made [${path} filter find comment=${comment}]`,
`:if ([:len $made] != 1) do={ :error "owned staged rule missing or duplicated" }`,
`:set new${family} ($new${family},$made)`,
`:local first [${path} filter find where dynamic=no]`,
`:if (($first->0) != ($made->0)) do={ ${path} filter move $made destination=($first->0) }`,
)
}
return lines
}
function snapshotFamily(family: Family): string[] {
const path = familyPath(family)
return [
// Prior successful transaction's retired rules are disabled and disposable.
`:foreach id in=[${path} filter find where comment~"^evofw-retired-"] do={`,
` :if ([${path} filter get $id disabled] != true) do={ :error "active retired rule needs manual recovery" }`,
` ${path} filter remove $id`,
'}',
// Reap only owned unreferenced banks, using an immutable print snapshot.
`:foreach row in=[${path} address-list print as-value where list~"^EVOFW_(DENY|ALLOW)_"] do={`,
' :local listName ($row->"list")',
` :if ([:len [${path} filter find where src-address-list=$listName]] = 0) do={ ${path} address-list remove [find list=$listName] }`,
'}',
`:foreach row in=[${path} filter print as-value] do={`,
' :local comment ($row->"comment")',
` :if ($comment~"${OWNED}" || $comment~"^evofw-stage-") do={ :set old${family} ($old${family},{$row}) }`,
'}',
]
}
for (const c of policy.denyCidrs) {
if (!isIpv4Cidr(c)) continue
function activateFamily(policy: EvaluatedPolicy, family: Family): string[] {
const path = familyPath(family)
const lines: string[] = []
for (const [name] of SPECS) {
const disabled = name === 'default-drop-forward' && policy.defaultAction === 'accept' ? 'yes' : 'no'
lines.push(`${path} filter set [find comment=("evofw-stage-" . $tx . "-${name}")] disabled=${disabled}`)
}
// Both family candidates are complete before any old protection is retired.
lines.push(
`:foreach row in=$old${family} do={`,
` ${path} filter set ($row->".id") disabled=yes comment=("evofw-retired-" . $tx . "-" . ($row->"comment"))`,
'}',
)
for (const [name] of SPECS) lines.push(`${path} filter set [find comment=("evofw-stage-" . $tx . "-${name}")] comment=evofw-${name}`)
return lines
}
function verifyFamily(policy: EvaluatedPolicy, family: Family): string[] {
const path = familyPath(family)
const lines: string[] = []
for (const [name, chain, action, kind] of SPECS) {
const disabled = name === 'default-drop-forward' && policy.defaultAction === 'accept' ? 'true' : 'false'
lines.push(
`/ip firewall address-list add list=EVOFW_DENY address=${escAddress(c)} comment=evofw`,
`:local id [${path} filter find comment=evofw-${name}]`,
`:if ([:len $id] != 1 || [${path} filter get $id chain] != "${chain}" || [${path} filter get $id action] != "${action}" || [${path} filter get $id disabled] != ${disabled}) do={ :error "owned filter chain/action drift after repair" }`,
)
}
for (const c of policy.allowCidrs) {
if (!isIpv4Cidr(c)) continue
if (kind) lines.push(`:if ([${path} filter get $id src-address-list] != $${kind}${family}) do={ :error "owned source bank drift after repair" }`)
else lines.push(`:if ([${path} filter get $id src-address-list] != "") do={ :error "owned default matcher drift after repair" }`)
lines.push(
`/ip firewall address-list add list=EVOFW_ALLOW address=${escAddress(c)} comment=evofw`,
':local permitted {".id";"chain";"action";"src-address-list";"disabled";"dynamic";"invalid";"bytes";"packets";"comment";"log";"log-prefix"}',
`:foreach row in=[${path} filter print as-value where comment=evofw-${name}] do={`,
' :if (($row->"invalid") = true || ($row->"dynamic") = true) do={ :error "owned rule is not active static configuration" }',
' :foreach key,value in=$row do={ :if ([:typeof [:find $permitted $key]] = "nil") do={ :error "unexpected owned matcher after repair" } }',
'}',
)
}
for (const chain of ['input', 'forward'] as const) {
lines.push(`:local ordered [${path} filter find where chain=${chain} and dynamic=no]`)
SPECS.filter((spec) => spec[1] === chain).forEach(([name], index) => lines.push(`:if (($ordered->${index}) != ([${path} filter find comment=evofw-${name}]->0)) do={ :error "owned filter order drift after repair" }`))
}
return lines
}
lines.push(
`:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-default-drop-forward] disabled=${defaultDropDisabled} } on-error={}`,
// Legacy comments from bl/wl toggle era — keep disabled
`:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=yes } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=yes } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=yes } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=yes } on-error={}`,
)
function rollbackFamily(family: Family): string[] {
const path = familyPath(family)
return [
':do {',
` :foreach row in=$old${family} do={ ${path} filter set ($row->".id") disabled=($row->"disabled") comment=($row->"comment") }`,
` ${path} filter remove $new${family}`,
'} on-error={ :set rollbackFailed true }',
]
}
return `${lines.join('\n')}\n`
/** RouterOS has no cross-family transaction: preserve full original rule
* snapshots and compensate both families if any stage/activation/verify fails. */
export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
if (!/^sha256:[0-9a-f]{64}$/.test(policy.hash) || !Number.isSafeInteger(policy.generation) || policy.generation < 0 || !Number.isSafeInteger(policy.applyVersion) || !['accept', 'drop'].includes(policy.defaultAction)) throw new Error('Invalid MikroTik policy metadata')
const rawCount = policy.denyCidrs.length + policy.allowCidrs.length + policy.portRules.reduce((n, rule) => n + rule.srcCidrs.length, 0)
if (rawCount > 200000 || policy.portRules.length > 1000 || Buffer.byteLength(JSON.stringify(policy), 'utf8') > 8388608) throw Object.assign(new Error('Raw policy budget exceeded'), { code: 'POLICY_BUDGET_EXCEEDED' })
const normalize = (cidrs: string[]) => [...new Set(cidrs.map((cidr) => {
if (typeof cidr !== 'string' || cidr.length > 128) throw new Error('Invalid CIDR in MikroTik policy')
return canonicalCidr(cidr)
}))]
policy = { ...policy, denyCidrs: normalize(policy.denyCidrs), allowCidrs: normalize(policy.allowCidrs), portRules: policy.portRules.map((rule) => ({ ...rule, srcCidrs: normalize(rule.srcCidrs) })) }
const effectiveCount = policy.denyCidrs.length + policy.allowCidrs.length + policy.portRules.reduce((n, rule) => n + new Set(rule.srcCidrs).size, 0)
if (effectiveCount > 50000 || Buffer.byteLength(JSON.stringify(policy), 'utf8') > 8388608) throw Object.assign(new Error('Effective policy budget exceeded'), { code: 'POLICY_BUDGET_EXCEEDED' })
for (const cidr of [...policy.denyCidrs, ...policy.allowCidrs]) addressFamily(cidr)
if (policy.portRules.length) throw Object.assign(new Error('MikroTik Port ACL is unsupported'), { code: 'UNSUPPORTED_CAPABILITY' })
const requiresIpv6 = [...policy.denyCidrs, ...policy.allowCidrs].some((cidr) => addressFamily(cidr) === 6)
const lines = [
`# evofw hash=${policy.hash} default_action=${policy.defaultAction} apply_version=${policy.applyVersion} gen=${policy.generation}`,
':do {',
':global EvofwRenderedHash', ':global EvofwRenderedRevision', ':global EvofwRenderedIpv6',
':global EvofwApplyErrorCode', ':set EvofwApplyErrorCode "apply_failed"',
':local v6Enabled false',
':do { :set v6Enabled (![/ipv6 settings get disable-ipv6]) } on-error={ :set v6Enabled false }',
...(requiresIpv6 ? [':if (!$v6Enabled) do={ :set EvofwApplyErrorCode "UNSUPPORTED_CAPABILITY"; :error "IPv6 enforcement unavailable" }'] : []),
':local tx [:rndstr length=12 from="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"]',
':local deny4 ("EVOFW_DENY_" . $tx)', ':local allow4 ("EVOFW_ALLOW_" . $tx)',
':local deny6 ("EVOFW_DENY_" . $tx)', ':local allow6 ("EVOFW_ALLOW_" . $tx)',
':local old4 {}', ':local old6 {}', ':local new4 {}', ':local new6 {}',
...snapshotFamily(4),
':if ($v6Enabled) do={', ...snapshotFamily(6), '}',
':do {',
...stageFamily(policy, 4),
':if ($v6Enabled) do={', ...stageFamily(policy, 6), '}',
...activateFamily(policy, 4),
':if ($v6Enabled) do={', ...activateFamily(policy, 6), '}',
...verifyFamily(policy, 4),
':if ($v6Enabled) do={', ...verifyFamily(policy, 6), '}',
'} on-error={',
' :local rollbackFailed false', ...rollbackFamily(4),
' :if ($v6Enabled) do={', ...rollbackFamily(6), ' }',
' :if ($rollbackFailed) do={ :set EvofwApplyErrorCode "rollback_failed"; :error "CRITICAL: both-family rollback failed" }',
' :error "both-family rollback completed; policy not acknowledged"',
'}',
// Publish the actual rendered generation only after both family verifies.
`:set EvofwRenderedHash "${policy.hash}"`, `:set EvofwRenderedRevision ${policy.generation}`,
':set EvofwRenderedIpv6 $v6Enabled',
'} on-error={ :error "evofw compiled policy failed; no acknowledgement" }',
]
const result = `${lines.join('\n')}\n`
if (Buffer.byteLength(result, 'utf8') > 8388608) throw Object.assign(new Error('Compiled policy exceeds 8MiB'), { code: 'POLICY_BUDGET_EXCEEDED' })
return result
}
@@ -1,4 +1,4 @@
import { resolve4, resolve6 } from 'node:dns/promises'
import { Resolver } from 'node:dns/promises'
import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
@@ -8,24 +8,32 @@ function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
}
/** Resolve FQDN to /32 and /128 CIDRs. Throws if nothing resolved. */
export const HOSTNAME_DNS_TIMEOUT_MS = 5000
/** Publish A/AAAA together; transient failures must never drop one family. */
export async function resolveHostnameToCidrs(hostname: string): Promise<string[]> {
const host = hostname.trim().replace(/\.$/, '').toLowerCase()
if (!host) throw new Error('hostname empty')
const out: string[] = []
try {
const a = await resolve4(host)
out.push(...a.map((ip) => `${ip}/32`))
} catch {
/* ignore A failures */
const resolver = new Resolver({ timeout: HOSTNAME_DNS_TIMEOUT_MS, tries: 1 })
const deadline = setTimeout(() => resolver.cancel(), HOSTNAME_DNS_TIMEOUT_MS)
deadline.unref()
const family = async (query: Promise<string[]>, bits: number) => {
try { return (await query).map(ip => `${ip}/${bits}`) }
catch (error) {
const code = (error as NodeJS.ErrnoException).code
// ENODATA is authoritative absence of this record family. ENOTFOUND
// means NXDOMAIN for the hostname, so a contradictory partial answer
// is not accepted. Any transient/cancelled query preserves last-good.
if (code === 'ENODATA') return []
throw new Error(`DNS resolve failed for ${host}: ${code || 'query failed'}`)
}
}
let out: string[]
try {
const aaaa = await resolve6(host)
out.push(...aaaa.map((ip) => `${ip}/128`))
} catch {
/* ignore AAAA failures */
}
const [a, aaaa] = await Promise.all([family(resolver.resolve4(host), 32), family(resolver.resolve6(host), 128)])
out = [...a, ...aaaa]
} finally { clearTimeout(deadline); resolver.cancel() }
const cidrs = uniqCidrs(out)
if (cidrs.length === 0) {
@@ -46,14 +54,14 @@ export async function resolveAndStoreHostnameRule(
.sort()
const nextHash = hashCidrs(cidrs)
const prevHash = hashCidrs(prev)
repos.replaceResolvedForRule(db, ruleId, cidrs)
if (nextHash !== prevHash) repos.replaceResolvedForRule(db, ruleId, cidrs)
return cidrs.length && nextHash !== prevHash ? cidrs : cidrs
}
/** Re-resolve all hostname rules; bump agents when cache changes. */
export async function refreshAllHostnameRules(db: Db): Promise<void> {
const rules = repos.listHostnameRules(db)
const changedSets = new Set<string>()
const staged: { rule: typeof rules[number]; previous: string; cidrs: string[] }[] = []
for (const rule of rules) {
if (!rule.hostname) continue
@@ -63,14 +71,25 @@ export async function refreshAllHostnameRules(db: Db): Promise<void> {
)
const cidrs = await resolveHostnameToCidrs(rule.hostname)
const next = hashCidrs(cidrs)
repos.replaceResolvedForRule(db, rule.id, cidrs)
if (prev !== next) changedSets.add(rule.setId)
if (prev !== next) staged.push({ rule, previous: prev, cidrs })
} catch {
/* keep previous cache on transient DNS failure */
}
}
for (const setId of changedSets) {
repos.bumpAgentsForSet(db, setId)
}
db.transaction((tx) => {
const changedSets = new Set<string>()
for (const { rule, previous, cidrs } of staged) {
const current = repos.getPolicyRule(tx, rule.id)
// A rule/cache may have been edited or another refresh published while DNS
// was pending. Never overwrite that newer publication with a stale lookup.
if (!current || current.hostname !== rule.hostname || current.setId !== rule.setId) continue
const currentHash = hashCidrs(repos.listResolvedForRule(tx, rule.id).map((r) => r.cidr))
if (currentHash !== previous) continue
repos.replaceResolvedForRule(tx, rule.id, cidrs)
changedSets.add(rule.setId)
}
const alreadyBumped = new Set<string>()
for (const setId of changedSets) repos.bumpAgentsForSet(tx, setId, alreadyBumped)
})
}
@@ -0,0 +1,212 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { createPolicySnapshot, startPolicyRollout, confirmPolicyRollout, rollbackPolicyRollout, releaseAgentPolicyPin, diffPolicySnapshot, getPolicyRolloutView } from './rollouts.js'
import { evaluateAgentPolicy } from './evaluate.js'
import { explainAgentPolicy } from './explain.js'
import { buildApp } from '../../app.js'
import { loadConfig } from '../../config.js'
const opened: ReturnType<typeof createMemoryDb>[] = []
afterEach(() => { vi.restoreAllMocks(); for (const mem of opened.splice(0)) mem.sqlite.close() })
const start = '2026-01-01T00:00:00.000Z'
const later = '2026-01-01T00:00:01.000Z'
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
for (const id of ['source', 'a', 'b', 'outside']) repos.insertAgent(mem.db, { id, name: id, tokenPrefix: id, tokenHash: id,
status: 'approved', telemetryVersion: 2, capabilitiesJson: '["policy-ipv4","policy-ipv6","policy-port-acl","policy-port-acl-ipv6"]' })
repos.insertPolicySet(mem.db, { id: 'candidate', name: 'Candidate' })
repos.insertPolicyRule(mem.db, { id: 'deny', setId: 'candidate', action: 'deny', cidr: '192.0.2.0/24', priority: 10 })
repos.setAgentPolicySets(mem.db, 'source', ['candidate'])
return mem
}
function begin(db: ReturnType<typeof memory>['db']) {
const snapshot = createPolicySnapshot(db, 'source', 'Candidate', start)
const rollout = startPolicyRollout(db, { snapshot_id: snapshot.id,
targets: ['a', 'b'].map((id) => ({ agent_id: id, expected_generation: repos.getAgent(db, id)!.policyGeneration })), canary_ids: ['a'] }, start)
return { snapshot, rollout }
}
function ack(db: ReturnType<typeof memory>['db'], id: string, now = later) {
const policy = evaluateAgentPolicy(db, id, now)
repos.updateAgent(db, id, { appliedHash: policy.hash, appliedRevision: policy.generation, lastApplyStatus: 'ok', lastApplyError: null, applyErrorJson: null, lastTelemetryAt: now })
}
describe('immutable policy snapshots and staged rollout', () => {
it('allows a policy reader to preview snapshot diffs while rejecting snapshot creation and rollout mutations', async () => {
const config = { ...loadConfig(), jwtSecret: 'snapshot-rbac-test-secret', authRequired: true, staticDir: null, logLevel: 'error' as const }
const app = await buildApp({ memory: true, config })
try {
await app.ready()
repos.insertAgent(app.db, { id: 'source', name: 'Source', tokenHash: 'source', tokenPrefix: 'source', status: 'approved' })
const snapshot = createPolicySnapshot(app.db, 'source', 'Read preview')
const headers = { authorization: `Bearer ${app.jwt.sign({ sub: 'reader', apps: ['fw'], permissions: ['fw:policies:read'], iss: config.authIssuer })}` }
expect((await app.inject({ method: 'POST', url: `/api/v1/policies/snapshots/${snapshot.id}/diff`, headers, payload: { agent_ids: ['source'] } })).statusCode).toBe(200)
expect((await app.inject({ method: 'POST', url: '/api/v1/policies/snapshots', headers, payload: { source_agent_id: 'source', name: 'Denied' } })).statusCode).toBe(403)
expect((await app.inject({ method: 'POST', url: '/api/v1/policies/rollouts', headers, payload: {} })).statusCode).toBe(403)
expect((await app.inject({ method: 'POST', url: '/api/v1/policies/rollouts/example/confirm', headers, payload: { expected_version: 1 } })).statusCode).toBe(403)
expect((await app.inject({ method: 'DELETE', url: `/api/v1/policies/snapshots/${snapshot.id}`, headers })).statusCode).toBe(403)
} finally { await app.close() }
})
it('terminates and cleans up 20 released-canary abandonments without repinning manually released targets', () => {
const { db } = memory()
const snapshot = createPolicySnapshot(db, 'source', 'Candidate', start)
const abandoned = []
for (let index = 0; index < 20; index++) {
const generation = repos.getAgent(db, 'a')!.policyGeneration
const rollout = startPolicyRollout(db, { snapshot_id: snapshot.id, targets: [{ agent_id: 'a', expected_generation: generation }, { agent_id: 'b', expected_generation: 1 }], canary_ids: ['a'] }, start)
releaseAgentPolicyPin(db, 'a', rollout.id, repos.getAgent(db, 'a')!.policyGeneration, later)
abandoned.push(rollout)
}
expect(repos.countActivePolicyRollouts(db)).toBe(20)
const releasedRevision = repos.getAgent(db, 'a')!.policyGeneration
for (const rollout of abandoned) {
const result = rollbackPolicyRollout(db, rollout.id, 1, later)
expect(result.state).toBe('rolled_back')
expect(result.targets.map((target) => target.phase)).toEqual(['released', 'pending'])
expect(repos.deletePolicyRollout(db, rollout.id)).toBe(1)
}
expect(repos.getPolicyPin(db, 'a')).toBeUndefined()
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(releasedRevision)
expect(repos.getAgent(db, 'b')!.policyGeneration).toBe(1)
expect(repos.countActivePolicyRollouts(db)).toBe(0)
expect(repos.countPolicyRollouts(db)).toBe(0)
expect(repos.deletePolicySnapshot(db, snapshot.id)).toBe(1)
})
it('rejects retained rollout capacity without creating snapshots, targets or partial pins', () => {
const { db, sqlite } = memory()
const snapshot = createPolicySnapshot(db, 'source', 'Candidate', start)
const insert = sqlite.prepare("INSERT INTO policy_rollouts VALUES (?,?,'rolled_back',2,?,?)")
for (let index = 0; index < 200; index++) insert.run(`retained-${index}`, snapshot.id, start, start)
expect(() => startPolicyRollout(db, { snapshot_id: snapshot.id, targets: [{ agent_id: 'a', expected_generation: 1 }], canary_ids: ['a'] }, start)).toThrow('capacity')
expect(repos.getPolicyPin(db, 'a')).toBeUndefined()
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(1)
expect(repos.policySnapshotCapacity(db).count).toBe(1)
expect(sqlite.prepare('SELECT count(*) AS n FROM policy_rollout_targets').get()).toEqual({ n: 0 })
})
it('pins canaries only, freezes source changes, confirms exact fresh ACKs, rolls back previous desired with new revisions', () => {
const { db } = memory()
const previous = evaluateAgentPolicy(db, 'a', start)
const { snapshot, rollout } = begin(db)
const canary = evaluateAgentPolicy(db, 'a', start)
expect(canary.hash).toBe(snapshot.hash)
expect(repos.getAgent(db, 'a')!.appliedHash).toBeNull()
expect(repos.getAgent(db, 'a')!.desiredHash).toBe(snapshot.hash)
expect(repos.getPolicyPin(db, 'b')).toBeUndefined()
expect(evaluateAgentPolicy(db, 'outside', start).generation).toBe(1)
expect(() => confirmPolicyRollout(db, rollout.id, 1, later)).toThrow('acknowledgement')
repos.updatePolicyRule(db, 'deny', { cidr: '198.51.100.0/24' })
repos.bumpAgentsForSet(db, 'candidate')
expect(evaluateAgentPolicy(db, 'a', later)).toEqual({ ...canary })
expect(evaluateAgentPolicy(db, 'source', later).hash).not.toBe(snapshot.hash)
const explanation = explainAgentPolicy(db, 'a', { ip: '192.0.2.1', chain: 'input', connection_state: 'new', dnat: false }, later)
expect(explanation.desired.decision).toBe('drop')
expect(explanation.desired.match!.label).toContain('Frozen policy snapshot')
ack(db, 'a')
const confirmed = confirmPolicyRollout(db, rollout.id, 1, later)
expect(confirmed.state).toBe('confirmed')
expect(evaluateAgentPolicy(db, 'b', later).hash).toBe(snapshot.hash)
expect(repos.getAgent(db, 'b')!.appliedHash).toBeNull()
expect(() => confirmPolicyRollout(db, rollout.id, 1, later)).toThrow('state changed')
const rolledBack = rollbackPolicyRollout(db, rollout.id, 2, later)
expect(rolledBack.state).toBe('rolled_back')
expect(evaluateAgentPolicy(db, 'a', later).hash).toBe(previous.hash)
expect(evaluateAgentPolicy(db, 'a', later).generation).toBeGreaterThan(canary.generation)
expect(repos.getAgent(db, 'a')!.appliedHash).toBe(snapshot.hash)
expect(rolledBack.targets.every((target) => !target.acknowledged)).toBe(true)
const released = releaseAgentPolicyPin(db, 'a', rollout.id, evaluateAgentPolicy(db, 'a', later).generation, later)
expect(released.generation).toBeGreaterThan(canary.generation)
expect(repos.getPolicyPin(db, 'a')).toBeUndefined()
})
it('rejects live TTL overrides on snapshot source and every rollback baseline before any publication', () => {
const { db, sqlite } = memory()
repos.insertOverride(db, { id: 'ttl', agentId: 'source', action: 'allow', cidr: '203.0.113.1/32', expiresAt: '2026-01-02T00:00:00.000Z' })
expect(() => createPolicySnapshot(db, 'source', 'Candidate', start)).toThrow('temporary override')
repos.deleteOverride(db, 'ttl')
const snapshot = createPolicySnapshot(db, 'source', 'Candidate', start)
repos.insertOverride(db, { id: 'ttl2', agentId: 'b', action: 'deny', cidr: '203.0.113.1/32', expiresAt: '2026-01-02T00:00:00.000Z' })
expect(() => startPolicyRollout(db, { snapshot_id: snapshot.id, targets: [{ agent_id: 'a', expected_generation: 1 }, { agent_id: 'b', expected_generation: 1 }], canary_ids: ['a'] }, start)).toThrow('temporary override')
expect(repos.getPolicyPin(db, 'a')).toBeUndefined()
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(1)
expect(sqlite.prepare('SELECT count(*) AS n FROM policy_rollouts').get()).toEqual({ n: 0 })
})
it('blocks policy mutations, exposes bounded diff, and refuses unsupported targets atomically', () => {
const { db } = memory()
const { snapshot } = begin(db)
expect(() => repos.setAgentPolicySets(db, 'a', ['candidate'])).toThrow('Release')
expect(() => repos.insertOverride(db, { id: 'x', agentId: 'a', cidr: '10.0.0.0/8', action: 'allow' })).toThrow('Release')
expect(() => repos.updateAgent(db, 'a', { defaultAction: 'drop' })).toThrow('Release')
expect(() => repos.insertAgentPortRule(db, { id: 'port', agentId: 'a', action: 'open', protocol: 'tcp', portStart: 22, portEnd: 22, srcKind: 'cidr', srcCidr: '0.0.0.0/0' })).toThrow('Release')
expect(() => repos.deleteAgent(db, 'b')).toThrow('Release')
const diff = diffPolicySnapshot(db, snapshot.id, ['a', 'b', 'outside'], later)
expect(diff.items[0]!.conflict).toBe('POLICY_PINNED')
expect(diff.items[1]!.added).toBe(1)
expect(diff.items.reduce((total, item) => total + item.examples.length, 0)).toBeLessThanOrEqual(100)
repos.updateAgent(db, 'outside', { telemetryVersion: 1 })
expect(() => startPolicyRollout(db, { snapshot_id: snapshot.id, targets: [{ agent_id: 'outside', expected_generation: 1 }], canary_ids: ['outside'] }, later)).toThrow('capabilities')
expect(repos.getPolicyPin(db, 'outside')).toBeUndefined()
})
it('checks canary activation time, freshness, exact revision, failure and pending generation at confirm', () => {
const { db } = memory()
const { rollout, snapshot } = begin(db)
ack(db, 'a', start)
expect(() => confirmPolicyRollout(db, rollout.id, 1, later)).toThrow('acknowledgement')
ack(db, 'a')
repos.updateAgent(db, 'a', { appliedRevision: 1 })
expect(() => confirmPolicyRollout(db, rollout.id, 1, later)).toThrow('acknowledgement')
ack(db, 'a')
repos.updateAgent(db, 'a', { lastApplyStatus: 'error' })
expect(() => confirmPolicyRollout(db, rollout.id, 1, later)).toThrow('acknowledgement')
ack(db, 'a')
expect(() => confirmPolicyRollout(db, rollout.id, 1, '2026-01-01T00:10:00.000Z')).toThrow('acknowledgement')
repos.bumpAgentGeneration(db, 'b')
expect(() => confirmPolicyRollout(db, rollout.id, 1, later)).toThrow('generation changed')
expect(repos.getPolicyPin(db, 'b')).toBeUndefined()
expect(getPolicyRolloutView(db, rollout.id, later).version).toBe(1)
expect(repos.getAgent(db, 'a')!.desiredHash).toBe(snapshot.hash)
})
it('deduplicates snapshots, enforces global storage capacity transactionally and keeps a pin if live recompile fails', () => {
const { db, sqlite } = memory()
const { rollout, snapshot } = begin(db)
expect(createPolicySnapshot(db, 'source', 'Duplicate label', later).id).toBe(snapshot.id)
// Capacity uses actual UTF-8 bytes, independently of the denormalized metadata.
sqlite.prepare('UPDATE policy_snapshots SET payload_bytes=0').run()
const actualBytes = sqlite.prepare('SELECT sum(length(CAST(payload_json AS BLOB))) AS n FROM policy_snapshots').get() as { n: number }
expect(repos.policySnapshotCapacity(db).bytes).toBe(actualBytes.n)
const capacity = vi.spyOn(repos, 'policySnapshotCapacity').mockReturnValue({ count: 2, bytes: 256 * 1024 * 1024 })
repos.insertOverride(db, { id: 'permanent', agentId: 'outside', cidr: '10.0.0.0/8', action: 'allow' })
expect(() => createPolicySnapshot(db, 'outside', 'No capacity', later)).toThrow('budget')
capacity.mockRestore()
repos.insertPolicySet(db, { id: 'bad', name: 'Bad' })
repos.setAgentPolicySets(db, 'source', ['bad'])
// Target live baseline becomes incompatible while the immutable candidate remains valid.
sqlite.prepare('UPDATE agents SET default_action = ? WHERE id = ?').run('drop', 'a')
repos.updateAgent(db, 'a', { capabilitiesJson: '[]' })
expect(() => releaseAgentPolicyPin(db, 'a', rollout.id, repos.getAgent(db, 'a')!.policyGeneration, later)).toThrow('capabilities')
expect(repos.getPolicyPin(db, 'a')!.snapshot_id).toBe(snapshot.id)
expect(repos.getAgent(db, 'a')!.policyGeneration).toBe(2)
})
it('enforces HTTP schemas and pin conflicts through actual endpoints', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } })
try {
await app.ready()
for (const id of ['source', 'a']) repos.insertAgent(app.db, { id, name: id, tokenPrefix: id, tokenHash: id, status: 'approved', telemetryVersion: 2, capabilitiesJson: '["policy-ipv4"]' })
const snapshot = await app.inject({ method: 'POST', url: '/api/v1/policies/snapshots', payload: { source_agent_id: 'source', name: 'Candidate' } })
expect(snapshot.statusCode).toBe(201)
expect(snapshot.json().payload_json).toBeUndefined()
const invalid = await app.inject({ method: 'POST', url: '/api/v1/policies/rollouts', payload: { snapshot_id: snapshot.json().id, targets: [{ agent_id: 'a', expected_generation: 1 }], canary_ids: ['missing'] } })
expect(invalid.statusCode).toBe(400)
const rollout = await app.inject({ method: 'POST', url: '/api/v1/policies/rollouts', payload: { snapshot_id: snapshot.json().id, targets: [{ agent_id: 'a', expected_generation: 1 }], canary_ids: ['a'] } })
expect(rollout.statusCode).toBe(201)
const conflict = await app.inject({ method: 'PUT', url: '/api/v1/agents/a/policy-sets', payload: { set_ids: [] } })
expect(conflict.statusCode).toBe(409)
expect(conflict.json().error.code).toBe('POLICY_PINNED')
const deletion = await app.inject({ method: 'DELETE', url: `/api/v1/policies/snapshots/${snapshot.json().id}` })
expect(deletion.statusCode).toBe(409)
expect((await app.inject({ url: '/api/v1/policies/snapshots?limit=51' })).statusCode).toBe(400)
} finally { await app.close() }
})
})
+166
View File
@@ -0,0 +1,166 @@
import { createHash, randomUUID } from 'node:crypto'
import { repos, type Db, type PolicySnapshotRow, type PolicyRolloutTargetRow } from '@evofw/db'
import { POLICY_BUDGET, type CreatePolicyRollout, type PolicyRollout } from '@evofw/shared'
import { AppError } from '../../plugins/error-handler.js'
import { compileAgentPolicy, evaluateAgentPolicy, type EvaluatedPolicy } from './evaluate.js'
const fail = (code: string, message: string, status = 409): never => { throw new AppError(code, message, status) }
const requireAgent = (db: Db, id: string) => repos.getAgent(db, id) ?? fail('NOT_FOUND', 'Agent not found', 404)
const requireSnapshot = (db: Db, id: string) => repos.getPolicySnapshot(db, id) ?? fail('NOT_FOUND', 'Snapshot not found', 404)
type SnapshotPayload = Omit<EvaluatedPolicy, 'generation' | 'syncIntervalSec'>
const payloadOf = (snapshot: PolicySnapshotRow): SnapshotPayload => JSON.parse(snapshot.payload_json)
function noTemporaryOverrides(db: Db, id: string, now: string) {
if (repos.listActiveOverrides(db, id, now).some((row) => row.expiresAt !== null)) fail('TEMPORARY_OVERRIDE_ACTIVE', 'A temporary override must expire or be removed before snapshot or rollout')
}
function snapshotOf(db: Db, id: string, name: string, now: string) {
requireAgent(db, id)
if (repos.getPolicyPin(db, id)) fail('POLICY_PINNED', 'Release source policy pin before creating a snapshot')
noTemporaryOverrides(db, id, now)
const { generation, syncIntervalSec: _, ...payload } = evaluateAgentPolicy(db, id, now)
const json = JSON.stringify(payload)
const contentHash = createHash('sha256').update(json).digest('hex')
const same = repos.findPolicySnapshot(db, contentHash)
if (same) return same
const bytes = Buffer.byteLength(json)
const capacity = repos.policySnapshotCapacity(db)
if (bytes > POLICY_BUDGET.jsonBytes || capacity.count >= 200 || capacity.bytes + bytes > 256 * 1024 * 1024) fail('SNAPSHOT_CAPACITY', 'Snapshot count or stored payload byte budget exceeded', 422)
const row: PolicySnapshotRow = { id: randomUUID(), name, source_agent_id: id, source_generation: generation, hash: payload.hash, content_hash: contentHash, payload_json: json, payload_bytes: bytes, created_at: now }
repos.insertPolicySnapshot(db, row)
return row
}
export function createPolicySnapshot(db: Db, agentId: string, name: string, now = new Date().toISOString()) {
return db.transaction((tx) => snapshotOf(tx, agentId, name, now))
}
function compatible(db: Db, agentId: string, payload: SnapshotPayload) {
const agent = requireAgent(db, agentId)
if (agent.status !== 'approved') fail('ROLLOUT_TARGET_UNAVAILABLE', 'Every target must be approved')
let capabilities: string[] = []
try { capabilities = JSON.parse(agent.capabilitiesJson) } catch { /* unknown capabilities cannot prove support */ }
const ipv6 = [...payload.denyCidrs, ...payload.allowCidrs].some((cidr) => cidr.includes(':')) || payload.portRules.some((rule) => rule.srcCidrs.some((cidr) => cidr.includes(':')))
const ipv6Acl = payload.portRules.some((rule) => rule.srcCidrs.some((cidr) => cidr.includes(':')))
if (agent.telemetryVersion !== 2 || !capabilities.includes('policy-ipv4') ||
(ipv6 && !capabilities.includes('policy-ipv6')) ||
(payload.portRules.length > 0 && (agent.platform === 'mikrotik' || !capabilities.includes('policy-port-acl') || /ipset|iptables/.test(agent.lastApplyKernelMethod ?? ''))) ||
(ipv6Acl && !capabilities.includes('policy-port-acl-ipv6'))) fail('UNSUPPORTED_POLICY', 'Target has not reported capabilities required by this snapshot')
}
function unpinnedTarget(db: Db, agentId: string, expected: number, now: string, payload: SnapshotPayload) {
const agent = requireAgent(db, agentId)
if (repos.getPolicyPin(db, agentId)) fail('POLICY_PINNED', 'Target already has a policy pin')
// Publish expiry before checking optimistic generation, so an expired override
// never sneaks into the captured previous desired state.
repos.expireOverridesForAgent(db, agentId, now)
if (requireAgent(db, agentId).policyGeneration !== expected) fail('REVISION_CONFLICT', 'Target policy generation changed; refresh preview')
noTemporaryOverrides(db, agentId, now)
compatible(db, agentId, payload)
return agent
}
export function startPolicyRollout(db: Db, body: CreatePolicyRollout, now = new Date().toISOString()) {
return db.transaction((tx) => {
const snapshot = requireSnapshot(tx, body.snapshot_id)
const payload = payloadOf(snapshot)
if (repos.countActivePolicyRollouts(tx) >= 20 || repos.countPolicyRollouts(tx) >= 200) fail('ROLLOUT_CAPACITY', 'Rollout capacity exceeded (20 active canaries or 200 retained rollouts)', 422)
// Validate all targets before capturing or publishing any snapshot or pin.
for (const target of body.targets) unpinnedTarget(tx, target.agent_id, target.expected_generation, now, payload)
const previous = body.targets.map((target) => snapshotOf(tx, target.agent_id, 'Previous desired policy', now))
const id = randomUUID()
repos.insertPolicyRollout(tx, { id, snapshot_id: snapshot.id, state: 'canary', version: 1, created_at: now, updated_at: now })
for (const [index, target] of body.targets.entries()) {
const agent = requireAgent(tx, target.agent_id)
const canary = body.canary_ids.includes(target.agent_id)
repos.insertPolicyRolloutTarget(tx, { rollout_id: id, agent_id: agent.id, previous_snapshot_id: previous[index]!.id,
baseline_generation: agent.policyGeneration, previous_delivered_hash: agent.lastPolicyDeliveredAt ? agent.desiredHash : null, previous_delivered_revision: agent.lastPolicyDeliveredAt ? agent.desiredHashGeneration : null,
previous_applied_hash: agent.appliedHash, previous_applied_revision: agent.appliedRevision,
is_canary: Number(canary), phase: 'pending', desired_revision: null, activated_at: null })
if (canary) repos.activatePolicyPin(tx, id, agent.id, snapshot.id, 'candidate', now)
}
return getPolicyRolloutView(tx, id, now)
})
}
function acknowledged(db: Db, target: PolicyRolloutTargetRow, hash: string, now: string) {
const agent = requireAgent(db, target.agent_id)
const sync = Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60
const freshness = Math.min(3_600_000, Math.max(180_000, 3 * sync * 1000))
const telemetry = Date.parse(agent.lastTelemetryAt ?? '')
const activation = Date.parse(target.activated_at ?? '')
return agent.appliedHash === hash && agent.appliedRevision === target.desired_revision && agent.lastApplyStatus === 'ok' && !agent.lastApplyError && !agent.applyErrorJson &&
Number.isFinite(telemetry) && telemetry > activation && telemetry <= Date.parse(now) && Date.parse(now) - telemetry <= freshness
}
export function getPolicyRolloutView(db: Db, id: string, now = new Date().toISOString()): PolicyRollout {
const row = repos.getPolicyRollout(db, id) ?? fail('NOT_FOUND', 'Rollout not found', 404)
const candidateHash = repos.getPolicySnapshotHash(db, row.snapshot_id)!
return { ...row, targets: repos.listPolicyRolloutTargets(db, id).map((target) => {
const agent = requireAgent(db, target.agent_id)
const hash = target.phase === 'rollback' ? repos.getPolicySnapshotHash(db, target.previous_snapshot_id)! : candidateHash
return { agent_id: agent.id, is_canary: !!target.is_canary, phase: target.phase, desired_revision: target.desired_revision,
desired_hash: target.desired_revision === null || target.phase === 'released' ? null : hash, applied_revision: agent.appliedRevision, applied_hash: agent.appliedHash,
acknowledged: target.phase !== 'pending' && target.phase !== 'released' && acknowledged(db, target, hash, now), previous_desired_snapshot_id: target.previous_snapshot_id }
}) }
}
function requireOwnedPin(db: Db, target: PolicyRolloutTargetRow, rolloutId: string) {
const pin = repos.getPolicyPin(db, target.agent_id)
if (pin?.rollout_id !== rolloutId || requireAgent(db, target.agent_id).policyGeneration !== target.desired_revision) fail('REVISION_CONFLICT', 'Rollout target pin or revision changed')
}
export function confirmPolicyRollout(db: Db, id: string, version: number, now = new Date().toISOString()) {
return db.transaction((tx) => {
const rollout = repos.getPolicyRollout(tx, id) ?? fail('NOT_FOUND', 'Rollout not found', 404)
if (rollout.version !== version || rollout.state !== 'canary') fail('REVISION_CONFLICT', 'Rollout state changed')
const snapshot = requireSnapshot(tx, rollout.snapshot_id)
const payload = payloadOf(snapshot)
const targets = repos.listPolicyRolloutTargets(tx, id)
for (const target of targets) {
if (target.is_canary) {
requireOwnedPin(tx, target, id)
compatible(tx, target.agent_id, payload)
if (!acknowledged(tx, target, snapshot.hash, now)) fail('CANARY_NOT_ACKNOWLEDGED', 'Every canary needs a fresh successful exact hash and revision acknowledgement')
} else unpinnedTarget(tx, target.agent_id, target.baseline_generation, now, payload)
}
if (!repos.transitionPolicyRollout(tx, id, version, 'confirmed', now)) fail('REVISION_CONFLICT', 'Rollout version changed')
for (const target of targets) if (!target.is_canary) repos.activatePolicyPin(tx, id, target.agent_id, snapshot.id, 'candidate', now)
return getPolicyRolloutView(tx, id, now)
})
}
export function rollbackPolicyRollout(db: Db, id: string, version: number, now = new Date().toISOString()) {
return db.transaction((tx) => {
const rollout = repos.getPolicyRollout(tx, id) ?? fail('NOT_FOUND', 'Rollout not found', 404)
if (rollout.version !== version || rollout.state === 'rolled_back') fail('REVISION_CONFLICT', 'Rollout state changed')
const targets = repos.listPolicyRolloutTargets(tx, id)
for (const target of targets) if (target.phase !== 'pending' && target.phase !== 'released') {
requireOwnedPin(tx, target, id)
compatible(tx, target.agent_id, payloadOf(requireSnapshot(tx, target.previous_snapshot_id)))
}
if (!repos.transitionPolicyRollout(tx, id, version, 'rolled_back', now)) fail('REVISION_CONFLICT', 'Rollout version changed')
for (const target of targets) if (target.phase !== 'pending' && target.phase !== 'released') repos.activatePolicyPin(tx, id, target.agent_id, target.previous_snapshot_id, 'rollback', now)
return getPolicyRolloutView(tx, id, now)
})
}
export function releaseAgentPolicyPin(db: Db, agentId: string, rolloutId: string, expectedGeneration: number, now = new Date().toISOString()) {
return db.transaction((tx) => {
const agent = requireAgent(tx, agentId)
const pin = repos.getPolicyPin(tx, agentId)
if (pin?.rollout_id !== rolloutId || agent.policyGeneration !== expectedGeneration) fail('REVISION_CONFLICT', 'Policy pin or revision changed')
// Validate live candidate first. Budget/compatibility failures keep the pin.
const candidate = compileAgentPolicy(tx, agentId, now)
compatible(tx, agentId, candidate)
repos.releasePolicyPin(tx, agentId, rolloutId, candidate.hash)
return { agent_id: agentId, generation: requireAgent(tx, agentId).policyGeneration, hash: candidate.hash }
})
}
export function diffPolicySnapshot(db: Db, id: string, agentIds: string[], now = new Date().toISOString()) {
const candidate = payloadOf(requireSnapshot(db, id))
let examplesLeft = 100
return { snapshot_id: id, items: agentIds.map((agentId) => {
const agent = requireAgent(db, agentId)
const before = evaluateAgentPolicy(db, agentId, now)
const beforeMemberships = new Set([...before.denyCidrs.map((cidr) => `deny:${cidr}`), ...before.allowCidrs.map((cidr) => `allow:${cidr}`)])
const afterMemberships = new Set([...candidate.denyCidrs.map((cidr) => `deny:${cidr}`), ...candidate.allowCidrs.map((cidr) => `allow:${cidr}`)])
const added = [...afterMemberships].filter((value) => !beforeMemberships.has(value))
const removed = [...beforeMemberships].filter((value) => !afterMemberships.has(value))
const samples = [...added.map((cidr) => ({ change: 'added', cidr })), ...removed.map((cidr) => ({ change: 'removed', cidr }))].slice(0, examplesLeft)
examplesLeft -= samples.length
let conflict: string | null = null
try { unpinnedTarget(db, agentId, agent.policyGeneration, now, candidate) } catch (error) { if (error instanceof AppError) conflict = error.code; else throw error }
return { agent_id: agentId, expected_generation: requireAgent(db, agentId).policyGeneration, before_hash: before.hash, candidate_hash: candidate.hash,
added: added.length, removed: removed.length, examples: samples, truncated: samples.length < added.length + removed.length,
default_action_changed: before.defaultAction !== candidate.defaultAction, port_rules_changed: JSON.stringify(before.portRules) !== JSON.stringify(candidate.portRules), conflict }
}) }
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
import { repos } from '@evofw/db'
import { linkEnrollmentCredential } from './enrollment.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
@@ -38,7 +40,7 @@ async function enrollApprovedLinux(
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
'x-evofw-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
},
payload: {
name,
+39
View File
@@ -1,4 +1,32 @@
import { repos } from '@evofw/db'
import { telemetryQualitySchema, applyErrorSchema, type AgentDiagnostics } from '@evofw/shared'
export function mapAgentDiagnostics(a: NonNullable<ReturnType<typeof repos.getAgent>>): AgentDiagnostics {
const parseJson = (text: string | null) => { try { return JSON.parse(text ?? 'null') as unknown } catch { return null } }
const parsedQuality = telemetryQualitySchema.safeParse(parseJson(a.qualityJson))
const quality = parsedQuality.success ? parsedQuality.data : { partial: true, truncated: false, unsupported: ['telemetry_v2'], lost_reports: 0 }
const parsedError = applyErrorSchema.safeParse(parseJson(a.applyErrorJson))
const applyError = parsedError.success ? parsedError.data : a.lastApplyError ? { code: 'AGENT_APPLY_ERROR', message: a.lastApplyError } : null
const rawCapabilities = parseJson(a.capabilitiesJson)
const capabilities = Array.isArray(rawCapabilities) ? rawCapabilities.filter((item): item is string => typeof item === 'string') : []
const rawDetailCounts = parseJson(a.detailCountsJson)
const desiredHash = a.desiredHashGeneration === a.policyGeneration ? a.desiredHash : null
let status: AgentDiagnostics['status'] = 'pending'
if (applyError?.code === 'UNSUPPORTED_CAPABILITY') status = 'unsupported'
else if (applyError || a.lastApplyStatus === 'error' || a.lastApplyStatus === 'failed') status = 'failed'
else if (a.lastApplyStatus === 'unsupported' || a.telemetryVersion !== 2 || quality.unsupported.some((feature) => /policy|ipv6/.test(feature))) status = a.lastTelemetryAt ? 'unsupported' : 'pending'
else if (desiredHash && desiredHash === a.appliedHash && a.policyGeneration === a.appliedRevision) status = 'in_sync'
else if (desiredHash && a.appliedHash && desiredHash !== a.appliedHash && a.appliedRevision === a.policyGeneration) status = 'drift'
else if (desiredHash && a.lastPolicyDeliveredAt && (!a.lastApplyAt || a.lastPolicyDeliveredAt > a.lastApplyAt)) status = 'applying'
return {
desired_revision: a.policyGeneration, desired_hash: desiredHash,
applied_revision: a.appliedRevision, applied_hash: a.appliedHash,
agent_version: a.clientVersion, capabilities,
last_contact_at: a.lastSeenAt, last_telemetry_at: a.lastTelemetryAt, last_apply_at: a.lastApplyAt,
apply_error: applyError, status, telemetry_version: a.telemetryVersion, quality,
detail_counts: rawDetailCounts && typeof rawDetailCounts === 'object' ? rawDetailCounts as AgentDiagnostics['detail_counts'] : null,
}
}
export function mapAgent(
a: NonNullable<ReturnType<typeof repos.getAgent>>,
@@ -33,6 +61,17 @@ export function mapAgent(
revoked_at: a.revokedAt,
install_curl: opts?.installCurl ?? null,
install_link_id: opts?.installLinkId ?? null,
diagnostics: mapAgentDiagnostics(a),
}
}
export function mapOverride(row: NonNullable<ReturnType<typeof repos.insertOverride>>, now = new Date().toISOString()) {
return {
id: row.id, agent_id: row.agentId, cidr: row.cidr, action: row.action, comment: row.comment,
reason: row.reason, created_by_user_id: row.createdByUserId, created_by_name: row.createdByName,
created_at: row.createdAt, expires_at: row.expiresAt,
expired: !!row.expiredAt || !!(row.expiresAt && row.expiresAt <= now),
expiration_enforcement: 'next_policy_sync' as const,
}
}
+121
View File
@@ -0,0 +1,121 @@
import { lookup } from 'node:dns/promises'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { isIP, type LookupFunction } from 'node:net'
export const SOURCE_LIMITS = { bytes: 8 * 1024 * 1024, elements: 100_000, timeoutMs: 45_000, redirects: 3, pages: 1000, totalBytes: 32 * 1024 * 1024 } as const
/** Conservative public destination classification, including mapped IPv4. */
export function isPublicSourceAddress(address: string): boolean {
const family = isIP(address)
if (family === 4) {
const [a, b, c] = address.split('.').map(Number)
return !(a === 0 || a === 10 || a === 127 || a! >= 224 ||
(a === 100 && b! >= 64 && b! <= 127) || (a === 169 && b === 254) ||
(a === 172 && b! >= 16 && b! <= 31) || (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)))) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || (a === 203 && b === 0 && c === 113))
}
if (family !== 6) return false
// A public IPv6 source must be globally routed unicast; mapped/transition
// addresses and special-purpose 2001::/23 + documentation ranges fail closed.
const normalized = new URL(`http://[${address}]/`).hostname.slice(1, -1).toLowerCase()
const head = Number.parseInt(normalized.split(':')[0]!, 16)
if (!(head >= 0x2000 && head <= 0x3fff) || head === 0x2002 || head === 0x3fff) return false
if (head === 0x2001) {
const second = Number.parseInt(normalized.split(':')[1] || '0', 16)
if (second <= 0x1ff || second === 0xdb8) return false
}
return true
}
function allowedInternalOrigin(url: URL): boolean {
// Exact origins only: no wildcard, suffix, CIDR or user-configured list bypass.
const entries = (process.env.EVOFW_SOURCE_ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean)
return entries.some(entry => {
const allowed = new URL(entry)
if (allowed.username || allowed.password || allowed.pathname !== '/' || allowed.search || allowed.hash || !['http:', 'https:'].includes(allowed.protocol)) {
throw new Error('Invalid EVOFW_SOURCE_ALLOWED_ORIGINS entry: use exact HTTP(S) origins')
}
return allowed.origin === url.origin
})
}
function validateUrl(input: string | URL): URL {
const url = new URL(input)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Source requires an HTTP(S) URL without embedded credentials')
url.hash = ''
const host = url.hostname.replace(/^\[|\]$/g, '')
if (isIP(host) && !allowedInternalOrigin(url) && !isPublicSourceAddress(host)) throw new Error('Source destination is not public or explicitly allowed')
return url
}
/** The checked address is returned to the socket itself; no second DNS lookup. */
export function sourceLookup(url: URL, resolve = lookup): LookupFunction {
return (_hostname, options, callback) => {
void resolve(url.hostname, { all: true, verbatim: true }).then(records => {
if (!records.length || (!allowedInternalOrigin(url) && records.some(record => !isPublicSourceAddress(record.address)))) {
callback(new Error('Source DNS destination is not public or explicitly allowed'), '', 4)
return
}
if (options.all) callback(null, records)
else callback(null, records[0]!.address, records[0]!.family)
}).catch(() => callback(new Error('Source DNS lookup failed or policy is invalid'), '', 4))
}
}
interface FetchOptions { headers?: Record<string, string>; maxBytes?: number; timeoutMs?: number }
export async function safeFetchJson(input: string | URL, options: FetchOptions = {}): Promise<{ data: unknown; bytes: number }> {
let url = validateUrl(input)
const initialOrigin = url.origin
let sendCredentials = true
const deadline = Date.now() + Math.min(options.timeoutMs ?? SOURCE_LIMITS.timeoutMs, SOURCE_LIMITS.timeoutMs)
const maxBytes = Math.min(options.maxBytes ?? SOURCE_LIMITS.bytes, SOURCE_LIMITS.bytes)
for (let redirects = 0; redirects <= SOURCE_LIMITS.redirects; redirects++) {
const remaining = deadline - Date.now()
if (remaining <= 0) throw new Error('Source request deadline exceeded')
const headers: Record<string, string> = { Accept: 'application/json', 'Accept-Encoding': 'identity' }
if (sendCredentials && url.origin === initialOrigin) Object.assign(headers, options.headers)
const result = await new Promise<{ location?: string; data?: unknown; bytes: number }>((resolve, reject) => {
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
method: 'GET', headers, agent: false, maxHeaderSize: 16 * 1024,
lookup: sourceLookup(url), signal: AbortSignal.timeout(remaining),
}, response => {
const status = response.statusCode || 0
if ([301, 302, 303, 307, 308].includes(status)) {
const location = response.headers.location
response.destroy()
if (!location) reject(new Error('Source redirect without Location'))
else resolve({ location, bytes: 0 })
return
}
if (status < 200 || status >= 300) { response.destroy(); reject(new Error(`Source HTTP ${status}`)); return }
if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
response.destroy(); reject(new Error('Source compressed response is not supported')); return
}
const length = Number(response.headers['content-length'])
if (Number.isFinite(length) && length > maxBytes) { response.destroy(); reject(new Error('Source response byte limit exceeded')); return }
const chunks: Buffer[] = []
let bytes = 0
response.on('data', (chunk: Buffer) => {
bytes += chunk.length
if (bytes > maxBytes) { response.destroy(); reject(new Error('Source response byte limit exceeded')); return }
chunks.push(chunk)
})
response.on('error', () => reject(new Error('Source response interrupted')))
response.on('end', () => {
try { resolve({ data: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown, bytes }) }
catch { reject(new Error('Source response is not valid JSON')) }
})
})
request.on('error', () => reject(new Error('Source request failed or timed out')))
request.end()
})
if (!result.location) return { data: result.data, bytes: result.bytes }
if (redirects === SOURCE_LIMITS.redirects) throw new Error('Source redirect limit exceeded')
const next = validateUrl(new URL(result.location, url))
if (url.protocol === 'https:' && next.protocol !== 'https:') throw new Error('Source HTTPS downgrade redirect rejected')
if (next.origin !== url.origin) sendCredentials = false
url = next
}
throw new Error('Source redirect limit exceeded')
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { repos } from '@evofw/db'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
describe('enrollment signing master exposure', () => {
it('masks configured and fallback signing masters, preserves sentinel updates and excludes values from audit', async () => {
const master = 'private-enrollment-signing-master'
const app = await buildApp({ memory: true, config: { ...loadConfig(), enrollSeed: master, authRequired: false, authAuditIngestSecret: null, staticDir: null, logLevel: 'silent' } })
try {
const settings = await app.inject({ url: '/api/v1/settings' })
expect(settings.json().enroll_seed).toBe('********')
expect(settings.body).not.toContain(master)
expect(settings.headers['cache-control']).toBe('no-store')
const context = await app.inject({ url: '/api/v1/install-context' })
expect(context.json()).not.toHaveProperty('enroll_seed')
expect(context.json().enroll_seed_configured).toBe(true)
expect(context.body).not.toContain(master)
expect((await app.inject({ method: 'PUT', url: '/api/v1/settings', payload: { enroll_seed: '********' } })).statusCode).toBe(200)
expect(repos.getSetting(app.db, 'enroll_seed')).toBe(master)
expect((await app.inject({ method: 'PUT', url: '/api/v1/settings', payload: { enroll_seed: 'explicit-new-signing-master' } })).statusCode).toBe(200)
expect(repos.getSetting(app.db, 'enroll_seed')).toBe('explicit-new-signing-master')
const audit = await app.inject({ url: '/api/v1/audit' })
expect(audit.body).not.toContain(master)
expect(audit.body).not.toContain('explicit-new-signing-master')
// Empty persisted value uses the configured fallback without disclosing it.
repos.setSetting(app.db, 'enroll_seed', '')
expect((await app.inject({ url: '/api/v1/settings' })).json().enroll_seed).toBe('********')
} finally { await app.close() }
})
})
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
const dns = vi.hoisted(() => ({ resolve4: vi.fn(), resolve6: vi.fn(), cancel: vi.fn() }))
vi.mock('node:dns/promises', async importOriginal => ({
...await importOriginal<typeof import('node:dns/promises')>(),
Resolver: class { resolve4 = dns.resolve4; resolve6 = dns.resolve6; cancel = dns.cancel },
}))
import { HOSTNAME_DNS_TIMEOUT_MS, resolveHostnameToCidrs, resolveAndStoreHostnameRule } from './policy/resolve-hostname.js'
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks() })
describe('complete hostname snapshots', () => {
it('retains both cached families when AAAA times out while A changes', async () => {
const { db, sqlite } = createMemoryDb(); runMigrations(sqlite)
try {
repos.insertPolicySet(db, { id: 'dns-set', name: 'DNS test' })
repos.insertPolicyRule(db, { id: 'dns-rule', setId: 'dns-set', priority: 10, action: 'deny', hostname: 'host.test' })
dns.resolve4.mockResolvedValue(['1.1.1.1']); dns.resolve6.mockResolvedValue(['2606:4700:4700::1111'])
await resolveAndStoreHostnameRule(db, 'dns-rule', 'host.test')
const before = repos.listResolvedForRule(db, 'dns-rule')
dns.resolve4.mockResolvedValue(['8.8.8.8']); dns.resolve6.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
await expect(resolveAndStoreHostnameRule(db, 'dns-rule', 'host.test')).rejects.toThrow('ETIMEOUT')
expect(repos.listResolvedForRule(db, 'dns-rule')).toEqual(before)
dns.resolve6.mockResolvedValue(['2001:4860:4860::8888'])
expect(await resolveAndStoreHostnameRule(db, 'dns-rule', 'host.test')).toEqual(['2001:4860:4860::8888/128', '8.8.8.8/32'])
} finally { sqlite.close() }
})
it('accepts authoritative per-family ENODATA but rejects NXDOMAIN and SERVFAIL', async () => {
dns.resolve4.mockResolvedValue(['8.8.8.8']); dns.resolve6.mockRejectedValue(Object.assign(new Error('no AAAA'), { code: 'ENODATA' }))
expect(await resolveHostnameToCidrs('host.test')).toEqual(['8.8.8.8/32'])
for (const code of ['ENOTFOUND', 'ESERVFAIL']) {
dns.resolve6.mockRejectedValue(Object.assign(new Error(code), { code }))
await expect(resolveHostnameToCidrs('host.test')).rejects.toThrow(code)
}
})
it('cancels both unresolved DNS queries at the shared deadline', async () => {
vi.useFakeTimers()
const pending: ((reason: Error) => void)[] = []
dns.resolve4.mockImplementation(() => new Promise((_, reject) => pending.push(reject)))
dns.resolve6.mockImplementation(() => new Promise((_, reject) => pending.push(reject)))
dns.cancel.mockImplementation(() => { for (const reject of pending) reject(Object.assign(new Error('cancelled'), { code: 'ECANCELLED' })) })
const result = expect(resolveHostnameToCidrs('host.test')).rejects.toThrow('ECANCELLED')
await vi.advanceTimersByTimeAsync(HOSTNAME_DNS_TIMEOUT_MS)
await result
expect(dns.cancel).toHaveBeenCalled()
})
})
@@ -0,0 +1,175 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import * as safe from './safe-fetch.js'
import { fetchEvobgpPages } from './evobgp.js'
import { refreshIpList } from './lists/refresh.js'
import { publishListCascade } from './lists/entries.js'
const server = createServer((req, res) => {
const path = req.url!
if (path === '/large') { res.write('['); res.end(' '.repeat(1024) + ']'); return }
if (path === '/declared') { res.setHeader('Content-Length', '100000'); res.end('[]'); return }
if (path === '/headers') { res.setHeader('X-Large', 'x'.repeat(20_000)); res.end('{}'); return }
if (path === '/slow') { res.write('['); setTimeout(() => res.end(']'), 150); return }
if (path === '/invalid') { res.end('not JSON'); return }
if (path === '/compressed') { res.setHeader('Content-Encoding', 'gzip'); res.end('[]'); return }
if (path === '/redirect') { res.writeHead(302, { Location: '/echo' }); res.end(); return }
if (path === '/loop') { res.writeHead(302, { Location: '/loop' }); res.end(); return }
if (path === '/private') { res.writeHead(302, { Location: 'http://127.0.0.2/secret' }); res.end(); return }
if (path === '/cross') { res.writeHead(302, { Location: `${numericOrigin}/echo` }); res.end(); return }
if (path === '/cross-back') { res.writeHead(302, { Location: `${numericOrigin}/back` }); res.end(); return }
if (path === '/back') { res.writeHead(302, { Location: `${origin}/echo` }); res.end(); return }
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ authorization: req.headers.authorization || null, cookie: req.headers.cookie || null }))
})
let origin: string
let numericOrigin: string
const databases: ReturnType<typeof createMemoryDb>[] = []
beforeAll(async () => {
await new Promise<void>(resolve => server.listen(0, resolve))
const port = (server.address() as AddressInfo).port
origin = `http://localhost:${port}`
numericOrigin = `http://127.0.0.1:${port}`
})
afterEach(() => {
vi.restoreAllMocks(); vi.unstubAllEnvs()
for (const memory of databases.splice(0)) memory.sqlite.close()
})
afterAll(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) })
function allow() { vi.stubEnv('EVOFW_SOURCE_ALLOWED_ORIGINS', `${origin},${numericOrigin}`) }
function memory() { const value = createMemoryDb(); runMigrations(value.sqlite); databases.push(value); return value }
function feed(db: ReturnType<typeof memory>['db'], id: string, config: Record<string, unknown> = {}) {
repos.insertIpList(db, { id, name: id, type: 'json_url', configJson: JSON.stringify({ url: 'https://feed.test/list', ...config }) })
repos.replaceIpListEntries(db, id, ['8.8.8.8/32'])
}
describe('source socket boundaries', () => {
it('rejects private, reserved, mapped and transition IPs; accepts public v4/v6', () => {
for (const ip of ['127.0.0.1', '10.1.1.1', '169.254.169.254', '172.16.0.1', '192.168.1.1', '100.64.0.1', '198.18.0.1', '224.1.1.1', '::1', 'fc00::1', 'fe80::1', '::ffff:8.8.8.8', '2002:7f00:1::', '2001:db8::1']) expect(safe.isPublicSourceAddress(ip), ip).toBe(false)
for (const ip of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) expect(safe.isPublicSourceAddress(ip), ip).toBe(true)
})
it('returns only the vetted DNS answers to the socket and rejects mixed/private resolution', async () => {
const resolver = vi.fn().mockResolvedValue([{ address: '8.8.8.8', family: 4 }])
const call = (all = false) => new Promise<unknown>((resolve, reject) => safe.sourceLookup(new URL('https://feed.test'), resolver)('feed.test', { all }, (err, address) => err ? reject(err) : resolve(address)))
expect(await call()).toBe('8.8.8.8')
expect(await call(true)).toEqual([{ address: '8.8.8.8', family: 4 }])
resolver.mockResolvedValue([{ address: '8.8.8.8', family: 4 }, { address: '127.0.0.1', family: 4 }])
await expect(call()).rejects.toThrow('not public')
vi.stubEnv('EVOFW_SOURCE_ALLOWED_ORIGINS', '%%%invalid')
await expect(call()).rejects.toThrow('policy is invalid')
})
it('blocks literal and DNS loopback without explicit origin allowlisting', async () => {
await expect(safe.safeFetchJson(`${numericOrigin}/echo`)).rejects.toThrow('not public')
await expect(safe.safeFetchJson(`${origin}/echo`)).rejects.toThrow('failed')
await expect(safe.safeFetchJson('file:///etc/passwd')).rejects.toThrow('HTTP(S)')
await expect(safe.safeFetchJson('https://user:pass@feed.test')).rejects.toThrow('credentials')
})
it('enforces chunked/declared byte limits, headers, JSON format, encoding and body deadline', async () => {
allow()
for (const path of ['/large', '/declared']) await expect(safe.safeFetchJson(`${origin}${path}`, { maxBytes: 32 })).rejects.toThrow('byte limit')
await expect(safe.safeFetchJson(`${origin}/headers`)).rejects.toThrow('failed')
await expect(safe.safeFetchJson(`${origin}/invalid`)).rejects.toThrow('valid JSON')
await expect(safe.safeFetchJson(`${origin}/compressed`)).rejects.toThrow('compressed')
await expect(safe.safeFetchJson(`${origin}/slow`, { timeoutMs: 30 })).rejects.toThrow(/interrupted|timed out/)
})
it('bounds redirects and strips secrets permanently on an origin change', async () => {
allow()
const headers = { Authorization: 'Bearer secret', Cookie: 'secret=value' }
expect((await safe.safeFetchJson(`${origin}/redirect`, { headers })).data).toEqual({ authorization: 'Bearer secret', cookie: 'secret=value' })
for (const path of ['/cross', '/cross-back']) expect((await safe.safeFetchJson(`${origin}${path}`, { headers })).data).toEqual({ authorization: null, cookie: null })
await expect(safe.safeFetchJson(`${origin}/loop`)).rejects.toThrow('redirect limit')
await expect(safe.safeFetchJson(`${origin}/private`, { headers })).rejects.toThrow('not public')
})
})
describe('bounded snapshot publication', () => {
it('bumps direct port-list agents and deduplicates L3 ancestor dependencies', async () => {
const { db, sqlite } = memory()
for (const id of ['port-only', 'many-parents']) repos.insertAgent(db, { id, name: id, tokenHash: id, tokenPrefix: id, status: 'approved' })
feed(db, 'child')
for (const id of ['parent-a', 'parent-b']) {
repos.insertIpList(db, { id, name: id, type: 'static', configJson: JSON.stringify({ items: [{ kind: 'list', value: 'child' }] }) })
repos.insertPolicySet(db, { id: `set-${id}`, name: id })
repos.insertPolicyRule(db, { id: `rule-${id}`, setId: `set-${id}`, priority: 10, action: 'deny', listId: id })
}
repos.setAgentPolicySets(db, 'many-parents', ['set-parent-a', 'set-parent-b'])
sqlite.prepare('INSERT INTO agent_port_rules(id,agent_id,action,protocol,port_start,port_end,src_kind,list_id) VALUES (?,?,?,?,?,?,?,?)').run('port-list', 'port-only', 'open', 'tcp', 443, 443, 'list', 'child')
const beforePort = repos.getAgent(db, 'port-only')!.policyGeneration
const beforeMany = repos.getAgent(db, 'many-parents')!.policyGeneration
await publishListCascade(db, 'child', { cidrs: ['1.1.1.1'] })
expect(repos.getAgent(db, 'port-only')!.policyGeneration).toBe(beforePort + 1)
expect(repos.getAgent(db, 'many-parents')!.policyGeneration).toBe(beforeMany + 1)
await publishListCascade(db, 'child', { cidrs: ['1.1.1.1/32'] })
expect(repos.getAgent(db, 'port-only')!.policyGeneration).toBe(beforePort + 1)
expect(repos.getAgent(db, 'many-parents')!.policyGeneration).toBe(beforeMany + 1)
})
it('quarantines unexpected empty data, preserves last success time and permits explicit empty', async () => {
const { db } = memory(); feed(db, 'f')
const before = repos.getIpList(db, 'f')!.refreshedAt
vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: [], bytes: 2 })
await refreshIpList(db, 'f')
expect(repos.listIpListEntries(db, 'f')).toHaveLength(1)
expect(repos.getIpList(db, 'f')!.lastError).toContain('quarantined')
expect(repos.getIpList(db, 'f')!.refreshedAt).toBe(before)
repos.updateIpList(db, 'f', { configJson: JSON.stringify({ url: 'https://feed.test', allow_empty: true }) })
await refreshIpList(db, 'f')
expect(repos.listIpListEntries(db, 'f')).toHaveLength(0)
expect(repos.getIpList(db, 'f')!.lastError).toBeNull()
})
it('discards stale remote results after a config edit', async () => {
const { db } = memory(); feed(db, 'f')
vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => {
repos.updateIpList(db, 'f', { configJson: JSON.stringify({ url: 'https://new.test' }) })
return { data: ['1.1.1.1'], bytes: 12 }
})
await refreshIpList(db, 'f')
expect(repos.getIpList(db, 'f')!.configJson).toContain('new.test')
expect(repos.listIpListEntries(db, 'f')[0]!.cidr).toBe('8.8.8.8/32')
})
it('coalesces the same list and limits global active refreshes to two', async () => {
const { db } = memory(); for (const id of ['a', 'b', 'c']) feed(db, id)
let active = 0; let maximum = 0
const fetcher = vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => {
active++; maximum = Math.max(active, maximum)
await new Promise(resolve => setTimeout(resolve, 10)); active--
return { data: ['1.1.1.1'], bytes: 12 }
})
const first = refreshIpList(db, 'a')
expect(refreshIpList(db, 'a')).toBe(first)
await Promise.all([first, refreshIpList(db, 'b'), refreshIpList(db, 'c')])
expect(maximum).toBe(2); expect(fetcher).toHaveBeenCalledTimes(3)
})
it('rejects excessive elements and incomplete/repeated cursor snapshots', async () => {
const fetcher = vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: { items: Array(safe.SOURCE_LIMITS.elements + 1).fill('x') }, bytes: 1 })
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('element limit')
fetcher.mockResolvedValue({ data: { items: [], has_more: true, next_cursor: 'same' }, bytes: 1 })
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('repeated')
fetcher.mockResolvedValue({ data: { items: [], has_more: true }, bytes: 1 })
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('Incomplete')
fetcher.mockResolvedValue({ data: { items: [], has_more: 'yes' }, bytes: 1 })
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('has_more')
})
it('bounds cumulative snapshot bytes, page count and elapsed pagination time', async () => {
let page = 0
const fetcher = vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => ({ data: { items: [], has_more: true, next_cursor: String(++page) }, bytes: safe.SOURCE_LIMITS.bytes }))
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('byte limit')
expect(fetcher).toHaveBeenCalledTimes(4)
page = 0; fetcher.mockClear().mockImplementation(async () => ({ data: { items: [], has_more: true, next_cursor: String(++page) }, bytes: 1 }))
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('page limit')
expect(fetcher).toHaveBeenCalledTimes(safe.SOURCE_LIMITS.pages)
let now = 1000
vi.spyOn(Date, 'now').mockImplementation(() => now)
fetcher.mockClear().mockImplementation(async () => { now += safe.SOURCE_LIMITS.timeoutMs + 1; return { data: { items: [], has_more: true, next_cursor: 'later' }, bytes: 1 } })
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('deadline')
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('rejects excessive JSON URL entries without replacing last good', async () => {
const { db } = memory(); feed(db, 'f')
vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: Array(safe.SOURCE_LIMITS.elements + 1).fill('1.1.1.1'), bytes: 1 })
await refreshIpList(db, 'f')
expect(repos.getIpList(db, 'f')!.lastError).toContain('element limit')
expect(repos.listIpListEntries(db, 'f')[0]!.cidr).toBe('8.8.8.8/32')
})
})
+196
View File
@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import { applyReportBodySchema, type ApplyReportV2Body } from '@evofw/shared'
import { ingestTelemetry } from './telemetry.js'
import { mapAgentDiagnostics } from './row-mappers.js'
const opened: ReturnType<typeof createMemoryDb>[] = []
const BASE_TIME = Date.now() - 86_400_000
afterEach(() => { for (const mem of opened.splice(0)) mem.sqlite.close() })
function memory() {
const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite)
repos.insertAgent(mem.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' })
return mem
}
function report(sequence: number, packets = 100, extra: Partial<ApplyReportV2Body> = {}): ApplyReportV2Body {
return {
telemetry_version: 2, report_id: `r-${sequence}`, sequence, boot_id: 'boot', observed_at: new Date(BASE_TIME + sequence * 60_000).toISOString(),
interval_ms: 60_000, counter_epoch: { traffic: 't', ip: 'i', port: 'p' }, capabilities: ['policy_ipv4'],
quality: { partial: false, truncated: false, unsupported: [], lost_reports: 0 }, status: 'ok', packets_dropped: packets, packets_accepted: packets,
ip_hits: [{ ip: '8.8.8.8', packets }], port_hits: [{ ip: '8.8.8.8', port: 22, protocol: 'tcp', packets }],
...extra,
}
}
const ingest = (db: ReturnType<typeof memory>['db'], body: ApplyReportV2Body) => ingestTelemetry(db, 'a', body, '127.0.0.1')
describe('telemetry v2 integrity', () => {
it('classifies an explicit unsupported-capability apply error before generic failed status', () => {
const { db } = memory()
const body = applyReportBodySchema.parse(report(1, 100, { status: 'error', apply_error: { code: 'UNSUPPORTED_CAPABILITY', message: 'IPv6 backend unavailable' } }))
ingestTelemetry(db, 'a', body, '127.0.0.1')
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('unsupported')
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).apply_error!.code).toBe('UNSUPPORTED_CAPABILITY')
})
it('recovers from status:error without optional text when the same acknowledgement succeeds', () => {
const { db } = memory()
const hash = `sha256:${'a'.repeat(64)}`
repos.updateAgent(db, 'a', { desiredHash: hash, desiredHashGeneration: 1 })
for (const [sequence, status] of [[1, 'ok'], [2, 'error'], [3, 'ok']] as const) {
const body = applyReportBodySchema.parse(report(sequence, 100 + sequence, { status, applied_hash: hash, applied_revision: 1 }))
ingestTelemetry(db, 'a', body, '127.0.0.1')
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe(status === 'error' ? 'failed' : 'in_sync')
}
})
it('marks detail baseline/decrease intervals partial even when traffic and producer quality are complete', () => {
const { db } = memory()
for (const [sequence, detail] of [[1, 100], [2, 105], [3, 1]] as const) {
const body = applyReportBodySchema.parse(report(sequence, 100 + sequence, {
ip_hits: [{ ip: '8.8.8.8', packets: detail }], port_hits: [{ ip: '8.8.8.8', port: 22, protocol: 'tcp', packets: detail }],
detail_counts: { ip_found: 1, ip_sent: 1, port_found: 1, port_sent: 1 },
}))
ingestTelemetry(db, 'a', body, '127.0.0.1')
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).quality.partial).toBe(sequence !== 2)
}
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(5)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(5)
})
it('requires the complete v2 envelope and rejects unsafe or negative counters', () => {
expect(applyReportBodySchema.safeParse({ telemetry_version: 2, status: 'ok' }).success).toBe(false)
for (const packets of [-1, Number.MAX_SAFE_INTEGER + 1, 1.5]) expect(applyReportBodySchema.safeParse(report(1, packets)).success).toBe(false)
expect(applyReportBodySchema.safeParse(report(1)).success).toBe(true)
expect(applyReportBodySchema.safeParse({ status: 'ok' }).success).toBe(true)
expect(applyReportBodySchema.safeParse(report(1, 100, { detail_counts: { ip_found: 1, ip_sent: 2, port_found: 1, port_sent: 1 } })).success).toBe(false)
expect(applyReportBodySchema.safeParse(report(1, 100, { ip_hits: [{ ip: '::1', packets: 1 }, { ip: '0:0:0:0:0:0:0:1', packets: 2 }] })).success).toBe(false)
})
it('persists detail found/sent coverage, marks truncation and avoids unsafe lifetime rounding', () => {
const { db } = memory()
ingest(db, report(1))
repos.updateAgent(db, 'a', { totalPacketsDropped: Number.MAX_SAFE_INTEGER })
ingest(db, report(2, 105, { detail_counts: { ip_found: 50, ip_sent: 1, port_found: 10, port_sent: 1 } }))
const diagnostics = mapAgentDiagnostics(repos.getAgent(db, 'a')!)
expect(diagnostics.detail_counts).toEqual({ ip_found: 50, ip_sent: 1, port_found: 10, port_sent: 1 })
expect(diagnostics.quality.truncated).toBe(true)
expect(diagnostics.quality.unsupported).toContain('traffic_totals_overflow')
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(Number.MAX_SAFE_INTEGER)
expect(repos.getTelemetryState(db, 'a')!.droppedBaseline).toBe(105)
})
it('deduplicates reports and rejects delayed/out-of-order updates before every effect', () => {
const { db } = memory()
expect(ingest(db, report(1)).accepted).toBe(true)
expect(ingest(db, report(2, 105)).accepted).toBe(true)
const before = repos.getAgent(db, 'a')
expect(ingest(db, report(2, 999)).duplicate).toBe(true)
expect(ingest(db, report(1, 999, { report_id: 'different-old-id' })).accepted).toBe(false)
expect(ingest(db, report(3, 999, { report_id: 'r-1' })).duplicate).toBe(true)
expect(repos.getAgent(db, 'a')).toEqual(before)
expect(repos.listStatsSamples(db, 'a')).toHaveLength(2)
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(5)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(5)
expect(ingestTelemetry(db, 'a', { status: 'ok', packets_dropped: 999 }, '127.0.0.1').accepted).toBe(false)
})
it('UI reset preserves baselines and samples: 100 -> reset -> 100 -> 105 adds 0,0,5', () => {
const { db } = memory()
ingest(db, report(1, 80)); ingest(db, report(2, 100))
repos.resetDisplayedStats(db, 'a')
expect(repos.getAgent(db, 'a')!.lastApplyPacketsDropped).toBe(100)
expect(repos.listStatsSamples(db, 'a')).toHaveLength(2)
ingest(db, report(3, 100))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(0)
expect(repos.listIpBlockStats(db, 'a')).toEqual([])
expect(repos.listPortBlockStatsAggregate(db, 'a')).toEqual([])
ingest(db, report(4, 105))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(5)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(5)
})
it('independent traffic/IP/port epochs establish baselines even when new absolutes are larger', () => {
const { db } = memory()
ingest(db, report(1)); ingest(db, report(2, 105))
ingest(db, report(3, 1000, { counter_epoch: { traffic: 'new', ip: 'i', port: 'p' } }))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(900)
ingest(db, report(4, 1005, { counter_epoch: { traffic: 'new', ip: 'new-i', port: 'p' } }))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(10)
expect(repos.listIpBlockStats(db, 'a')[0]!.packets).toBe(900)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(905)
ingest(db, report(5, 2000, { boot_id: 'reboot', counter_epoch: { traffic: 'new', ip: 'new-i', port: 'p' } }))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(10)
expect(repos.listPortBlockStatsAggregate(db, 'a')[0]!.packets).toBe(905)
})
it('missing counter values and omitted detail rows preserve baselines; decreasing counters are partial', () => {
const { db } = memory()
ingest(db, report(1))
expect(repos.listStatsSamples(db, 'a')[0]!.deltaDropped).toBeNull()
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).quality.partial).toBe(true)
ingest(db, report(2, 100, { packets_dropped: undefined, packets_accepted: undefined, ip_hits: [], port_hits: [] }))
expect(repos.listStatsSamples(db, 'a').find((sample) => sample.sequence === 2)!.deltaDropped).toBeNull()
expect(repos.getTelemetryState(db, 'a')!.droppedBaseline).toBe(100)
ingest(db, report(3, 105))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
ingest(db, report(4, 1))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).quality.partial).toBe(true)
})
it('flags increasing sequence with a backward observation clock and leaves interval coverage unknown', () => {
const { db } = memory()
ingest(db, report(1))
ingest(db, report(2, 105, { observed_at: '2020-01-01T00:00:00Z' }))
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
const sample = repos.listStatsSamples(db, 'a').find((row) => row.sequence === 2)!
expect(sample.intervalMs).toBeNull()
expect(JSON.parse(sample.qualityJson!).unsupported).toContain('observed_at_out_of_order')
expect(repos.getTelemetryState(db, 'a')!.observedAt).toBe(report(1).observed_at)
})
it('rolls back baseline, totals, details, diagnostics and replay watermark if sample persistence fails', () => {
const { db, sqlite } = memory()
ingest(db, report(1))
const before = repos.getAgent(db, 'a')
sqlite.exec("CREATE TRIGGER fail_sample BEFORE INSERT ON agent_stats_samples BEGIN SELECT RAISE(ABORT, 'injected sample failure'); END;")
expect(() => ingest(db, report(2, 105))).toThrow('injected sample failure')
expect(repos.getAgent(db, 'a')).toEqual(before)
expect(repos.getTelemetryState(db, 'a')!.sequence).toBe(1)
expect(repos.listIpBlockStats(db, 'a')).toEqual([])
sqlite.exec('DROP TRIGGER fail_sample')
expect(ingest(db, report(2, 105)).accepted).toBe(true)
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(5)
})
it('keeps sequence watermark across raw retention and marks legacy telemetry limited', () => {
const { db } = memory()
ingestTelemetry(db, 'a', { status: 'ok', packets_dropped: 100 }, '127.0.0.1')
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('unsupported')
ingest(db, report(5, 1000))
repos.deleteStatsSamplesForAgent(db, 'a')
expect(ingest(db, report(3, 999, { report_id: 'old' })).accepted).toBe(false)
expect(repos.getAgent(db, 'a')!.totalPacketsDropped).toBe(100)
})
it('diagnostics distinguish acknowledged content, drift, apply failures and later successful retries', () => {
const { db } = memory()
const desired = `sha256:${'a'.repeat(64)}`
repos.updateAgent(db, 'a', { desiredHash: desired, desiredHashGeneration: 1, lastPolicyDeliveredAt: '2026-01-01T00:00:00Z' })
ingest(db, report(1, 100, { applied_hash: desired, applied_revision: 1 }))
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('in_sync')
ingest(db, report(2, 101, { status: 'error', apply_error: { code: 'KERNEL_CHECK_FAILED', message: 'candidate invalid' } }))
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('failed')
ingest(db, report(3, 102))
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('failed')
ingest(db, report(4, 103, { applied_hash: desired, applied_revision: 1 }))
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('in_sync')
ingest(db, report(5, 104, { applied_hash: `sha256:${'b'.repeat(64)}`, applied_revision: 1 }))
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('drift')
ingest(db, report(6, 105, { applied_hash: desired, applied_revision: 1 }))
expect(mapAgentDiagnostics(repos.getAgent(db, 'a')!).status).toBe('in_sync')
})
})
+158
View File
@@ -0,0 +1,158 @@
import { createHash } from 'node:crypto'
import { repos, type Db } from '@evofw/db'
import { type ApplyReportBody, type TelemetryQuality, hostFwRuleSchema, hostListenerSchema } from '@evofw/shared'
import { buildStatsRollups } from './analytics.js'
const limitedQuality: TelemetryQuality = { partial: true, truncated: false, unsupported: ['telemetry_v2'], lost_reports: 0 }
const epochKey = (boot: string, epoch: string) => JSON.stringify([boot, epoch])
/** All report effects share one transaction; replay decisions precede every write. */
export function ingestTelemetry(db: Db, agentId: string, body: ApplyReportBody, contactIp: string, now = new Date().toISOString()) {
return db.transaction((tx) => {
const agent = repos.getAgent(tx, agentId)
if (!agent) throw new Error('Agent not found')
const state = repos.getTelemetryState(tx, agentId)
const v2 = body.telemetry_version === 2 ? body : null
if (v2 && (repos.hasTelemetryReport(tx, agentId, v2.report_id) || state?.reportId === v2.report_id)) return { ok: true, accepted: false, duplicate: true }
if (v2 && state && v2.sequence <= state.sequence) return { ok: true, accepted: false, duplicate: false, reason: 'stale_sequence' }
// Delayed legacy payloads cannot overwrite newer v2 baselines or diagnostics.
if (!v2 && state) return { ok: true, accepted: false, duplicate: false, reason: 'legacy_after_v2' }
const quality: TelemetryQuality = v2 ? { ...v2.quality, unsupported: [...v2.quality.unsupported] } : { ...limitedQuality, unsupported: [...limitedQuality.unsupported] }
const markPartial = (feature: string) => {
quality.partial = true
if (!quality.unsupported.includes(feature)) quality.unsupported.push(feature)
}
if (v2 && state) quality.lost_reports = Math.max(quality.lost_reports, v2.sequence - state.sequence - 1)
if (quality.lost_reports > 0) quality.partial = true
const hasDetailCollection = !!(v2 && (v2.ip_hits !== undefined || v2.port_hits !== undefined || v2.capabilities.some((feature) => /^(ip|port)[-_]counters/.test(feature))))
if (v2 && hasDetailCollection && !v2.detail_counts) markPartial('detail_counts')
if (v2?.detail_counts && (v2.detail_counts.ip_sent < v2.detail_counts.ip_found || v2.detail_counts.port_sent < v2.detail_counts.port_found)) {
quality.truncated = true
quality.partial = true
}
const observationOutOfOrder = !!(v2 && state && Date.parse(v2.observed_at) < Date.parse(state.observedAt))
if (observationOutOfOrder) markPartial('observed_at_out_of_order')
const observationFuture = !!(v2 && Date.parse(v2.observed_at) > Date.parse(now) + 5 * 60_000)
if (observationFuture) markPartial('observed_at_future')
const trafficEpoch = v2 ? epochKey(v2.boot_id, v2.counter_epoch.traffic) : null
const sameEpoch = !!(v2 && state && trafficEpoch === state.trafficEpoch)
const nextCounter = (incoming: number | undefined, previous: number | null | undefined, feature: string) => {
if (incoming === undefined) return { baseline: sameEpoch ? previous ?? null : null, delta: null }
if (!v2) return { baseline: incoming, delta: incoming >= (previous ?? 0) ? incoming - (previous ?? 0) : incoming }
if (!sameEpoch || previous === null || previous === undefined) return { baseline: incoming, delta: null }
if (incoming < previous) { markPartial(`${feature}_counter_decrease`); return { baseline: incoming, delta: null } }
return { baseline: incoming, delta: incoming - previous }
}
const dropped = nextCounter(body.packets_dropped, v2 ? state?.droppedBaseline : agent.lastApplyPacketsDropped, 'dropped')
const accepted = nextCounter(body.packets_accepted, v2 ? state?.acceptedBaseline : agent.lastApplyPacketsAccepted, 'accepted')
if (body.packets_dropped === undefined || body.packets_accepted === undefined) markPartial('traffic_counters')
if (dropped.delta === null || accepted.delta === null) quality.partial = true
const addTotal = (total: number, delta: number) => {
const result = total + delta
if (!Number.isSafeInteger(result)) { markPartial('traffic_totals_overflow'); return total }
return result
}
const totalDropped = addTotal(agent.totalPacketsDropped, dropped.delta ?? 0)
const totalAccepted = addTotal(agent.totalPacketsAccepted, accepted.delta ?? 0)
const observed = v2?.observed_at ?? now
const validStart = (at: string | null | undefined, delta: number | null) => !observationOutOfOrder && !observationFuture && delta !== null && at && Date.parse(at) < Date.parse(observed) ? at : null
const droppedStart = validStart(v2 ? sameEpoch ? state?.droppedBaselineAt : null : agent.lastTelemetryAt, dropped.delta)
const acceptedStart = validStart(v2 ? sameEpoch ? state?.acceptedBaselineAt : null : agent.lastTelemetryAt, accepted.delta)
if (!droppedStart || !acceptedStart) quality.partial = true
const baselineAt = (incoming: number | undefined, previous: string | null | undefined) => incoming === undefined
? sameEpoch ? previous ?? null : null
: observationOutOfOrder || observationFuture ? null : observed
if (!v2 && body.packets_dropped !== undefined && body.packets_dropped < agent.lastApplyPacketsDropped) repos.resetIpBlockStatsBaselines(tx, agentId)
if (body.ip_hits?.length) {
if (v2 && !v2.counter_epoch.ip && body.source !== 'mikrotik') markPartial('ip_counter_epoch')
else repos.upsertIpBlockStats(tx, agentId, body.ip_hits, now, {
mode: body.source === 'mikrotik' ? 'presence' : 'absolute',
...(v2?.counter_epoch.ip ? { counterEpoch: epochKey(v2.boot_id, v2.counter_epoch.ip) } : {}),
onOverflow: () => markPartial('ip_totals_overflow'),
onUnknownInterval: () => { quality.partial = true },
})
}
if (body.port_hits?.length && body.source !== 'mikrotik') {
if (v2 && !v2.counter_epoch.port) markPartial('port_counter_epoch')
else repos.upsertPortBlockStats(tx, agentId, body.port_hits, now, {
...(v2?.counter_epoch.port ? { counterEpoch: epochKey(v2.boot_id, v2.counter_epoch.port) } : {}),
onOverflow: () => markPartial('port_totals_overflow'),
onUnknownInterval: () => { quality.partial = true },
})
}
const evicted = repos.enforceDetailBudget(tx, agentId)
if (evicted.ip || evicted.port) { markPartial('detail_storage_budget'); quality.truncated = true }
const succeeded = body.status === 'ok'
const reportedError = body.apply_error ?? (body.error ? { code: 'AGENT_APPLY_ERROR', message: body.error } : null)
const acknowledged = !!(v2 && succeeded && v2.applied_hash)
const newAcknowledgement = !!(v2 && acknowledged && (v2.applied_hash !== agent.appliedHash || (v2.applied_revision ?? null) !== agent.appliedRevision || agent.lastApplyError || (agent.lastApplyStatus !== null && agent.lastApplyStatus !== 'ok')))
const clearApplyError = !v2 || acknowledged
repos.updateAgent(tx, agentId, {
telemetryVersion: v2 ? 2 : 1,
lastTelemetryAt: now,
lastApplyAt: !v2 || newAcknowledgement || !succeeded ? now : agent.lastApplyAt,
lastApplyStatus: !v2 || !succeeded || newAcknowledgement ? body.status : agent.lastApplyStatus,
lastApplyError: reportedError?.message ?? (clearApplyError ? null : agent.lastApplyError),
applyErrorJson: reportedError ? JSON.stringify(reportedError) : clearApplyError ? null : agent.applyErrorJson,
lastApplyPrefixCount: body.prefix_count ?? agent.lastApplyPrefixCount,
lastApplyPacketsDropped: body.packets_dropped ?? agent.lastApplyPacketsDropped,
lastApplyPacketsAccepted: body.packets_accepted ?? agent.lastApplyPacketsAccepted,
totalPacketsDropped: totalDropped,
totalPacketsAccepted: totalAccepted,
lastApplyKernelMethod: body.kernel_method ?? agent.lastApplyKernelMethod,
lastSeenAt: now,
lastSeenIp: contactIp,
...(v2 ? {
clientVersion: v2.agent_version ?? agent.clientVersion,
capabilitiesJson: JSON.stringify(v2.capabilities),
appliedHash: succeeded ? v2.applied_hash ?? agent.appliedHash : agent.appliedHash,
appliedRevision: acknowledged ? v2.applied_revision ?? null : agent.appliedRevision,
} : {}),
qualityJson: JSON.stringify(quality),
detailCountsJson: v2?.detail_counts ? JSON.stringify(v2.detail_counts) : null,
})
repos.insertStatsSample(tx, {
id: crypto.randomUUID(), agentId,
packetsDropped: body.packets_dropped ?? agent.lastApplyPacketsDropped,
packetsAccepted: body.packets_accepted ?? agent.lastApplyPacketsAccepted,
prefixCount: body.prefix_count ?? agent.lastApplyPrefixCount ?? 0,
kernelMethod: body.kernel_method ?? agent.lastApplyKernelMethod,
recordedAt: now,
telemetryVersion: v2 ? 2 : 1,
reportId: v2?.report_id, sequence: v2?.sequence,
observedAt: v2?.observed_at ?? now,
intervalMs: droppedStart && acceptedStart ? Date.parse(observed) - Math.min(Date.parse(droppedStart), Date.parse(acceptedStart)) : null,
droppedIntervalStart: droppedStart, acceptedIntervalStart: acceptedStart,
droppedPresent: body.packets_dropped === undefined ? 0 : 1,
acceptedPresent: body.packets_accepted === undefined ? 0 : 1,
analyticsProcessed: 1,
deltaDropped: dropped.delta, deltaAccepted: accepted.delta,
qualityJson: JSON.stringify(quality),
detailCountsJson: v2?.detail_counts ? JSON.stringify(v2.detail_counts) : null,
})
if (v2) repos.upsertTelemetryState(tx, {
agentId, sequence: v2.sequence, reportId: v2.report_id,
bootId: v2.boot_id, trafficEpoch: trafficEpoch!, observedAt: observationOutOfOrder || observationFuture ? state?.observedAt ?? now : v2.observed_at, receivedAt: now,
droppedBaseline: dropped.baseline, acceptedBaseline: accepted.baseline,
droppedBaselineAt: baselineAt(body.packets_dropped, state?.droppedBaselineAt),
acceptedBaselineAt: baselineAt(body.packets_accepted, state?.acceptedBaselineAt),
})
repos.addStatsRollups(tx, buildStatsRollups(agentId,
{ start: droppedStart, end: observed, delta: dropped.delta },
{ start: acceptedStart, end: observed, delta: accepted.delta }, quality, now, !v2))
if (body.host_firewall && body.source !== 'mikrotik') {
const rules = body.host_firewall.rules.flatMap((item) => { const parsed = hostFwRuleSchema.safeParse(item); return parsed.success ? [parsed.data] : [] })
const listeners = body.host_firewall.listeners.flatMap((item) => { const parsed = hostListenerSchema.safeParse(item); return parsed.success ? [parsed.data] : [] })
if (rules.length || listeners.length || !repos.getHostFirewallSnapshot(tx, agentId)) {
const payload = JSON.stringify({ rules, listeners })
const digest = createHash('sha256').update(payload).digest('hex').slice(0, 16)
repos.upsertHostFirewallSnapshot(tx, agentId, payload, now, digest)
}
}
return { ok: true, accepted: true, duplicate: false }
})
}
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it } from 'vitest'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
import { repos } from '@evofw/db'
import { uiSummarySchema } from '@evofw/shared'
import { hashToken } from '../plugins/auth.js'
const apps: Awaited<ReturnType<typeof buildApp>>[] = []
afterEach(async () => { for (const app of apps.splice(0)) await app.close() })
async function fixture() {
const config = { ...loadConfig(), authRequired: true, jwtSecret: 'test-secret-key', authIssuer: 'https://auth.test', staticDir: null, logLevel: 'error' }
const app = await buildApp({ memory: true, config }); apps.push(app); await app.ready()
repos.insertAgent(app.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: hashToken('token-a'), status: 'approved', lastSeenAt: new Date().toISOString(), totalPacketsDropped: 15 })
repos.insertAgent(app.db, { id: 'b', name: 'b', tokenPrefix: 'b', tokenHash: 'token-b', status: 'approved' })
repos.insertAgent(app.db, { id: 'c', name: 'c', tokenPrefix: 'c', tokenHash: 'token-c', status: 'pending' })
repos.insertIpList(app.db, { id: 'l', name: 'l', type: 'static', lastError: 'upstream failed' })
const headers = (permissions: string[], allowedApps = ['fw']) => ({ authorization: `Bearer ${app.jwt.sign({ sub: 'viewer', apps: allowedApps, permissions, iss: config.authIssuer })}` })
return { app, headers }
}
describe('UI summary and telemetry routes', () => {
it('exposes explain under agents permission and records bounded override TTL/reason with server-owned author', async () => {
const { app, headers } = await fixture()
const write = headers(['fw:agents:write']), read = headers(['fw:agents:read'])
const create = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '8.8.8.8', action: 'deny', reason: 'Investigation', ttl_seconds: 60, created_by_user_id: 'spoofed' } })
expect(create.statusCode).toBe(200)
expect(create.json()).toMatchObject({ cidr: '8.8.8.8/32', created_by_user_id: 'viewer', reason: 'Investigation', expired: false, expiration_enforcement: 'next_policy_sync' })
expect(Date.parse(create.json().expires_at) - Date.now()).toBeGreaterThan(55_000)
const explain = await app.inject({ url: '/api/v1/agents/a/explain?ip=8.8.8.8', headers: read })
expect(explain.statusCode).toBe(200)
expect(explain.json().desired).toMatchObject({ decision: 'drop', stage: 'deny', match: { override_id: create.json().id } })
expect((await app.inject({ url: '/api/v1/agents/a/explain?ip=8.8.8.8', headers: headers(['fw:stats:read']) })).statusCode).toBe(403)
const both = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '1.1.1.1', action: 'deny', ttl_seconds: 60, expires_at: new Date(Date.now() + 60_000).toISOString() } })
expect(both.statusCode).toBe(400)
const duplicate = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '8.8.8.8', action: 'allow' } })
expect(duplicate.statusCode).toBe(409)
app.sqlite.prepare('UPDATE ip_overrides SET expires_at=? WHERE id=?').run(new Date(Date.now() - 1000).toISOString(), create.json().id)
const before = repos.getAgent(app.db, 'a')!.policyGeneration
const policy = await app.inject({ url: '/v1/agent/policy', headers: { authorization: 'Bearer token-a' } })
expect(policy.statusCode).toBe(200)
expect(policy.json().deny_cidrs).not.toContain('8.8.8.8/32')
expect(policy.json().generation).toBe(before + 1)
expect((await app.inject({ url: '/api/v1/agents/a/overrides', headers: read })).json().items[0].expired).toBe(true)
const replacement = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '8.8.8.8', action: 'deny', reason: 'New investigation', ttl_seconds: 60 } })
expect(replacement.statusCode).toBe(200)
})
it('accepts a real >1MiB 100k-prefix import with a scoped body limit and rejects excess elements', async () => {
const { app, headers } = await fixture()
const entries = Array.from({ length: 100_000 }, (_, i) => `10.${i >> 16}.${(i >> 8) & 255}.${i & 255}`)
const payload = { name: 'large-manual', type: 'static', entries }
expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(1024 * 1024)
const result = await app.inject({ method: 'POST', url: '/api/v1/lists', headers: headers(['fw:lists:write']), payload })
expect(result.statusCode).toBe(200)
expect(repos.countEntriesByListIds(app.db, [result.json().id]).get(result.json().id)).toBe(100_000)
const excessive = await app.inject({ method: 'POST', url: `/api/v1/lists/${result.json().id}/entries`, headers: headers(['fw:lists:write']), payload: { values: [Array(100_001).fill('8.8.8.8').join(' ')] } })
expect(excessive.statusCode).toBe(400)
const unrelated = await app.inject({ method: 'PATCH', url: '/api/v1/agents/a', headers: headers(['fw:agents:write']), payload: { name: 'x'.repeat(1024 * 1024 + 1) } })
expect(unrelated.statusCode).toBe(413)
}, 15_000)
it('protects bounded analytics/CSV/raw endpoints with stats permission and paginates tied raw timestamps without loss', async () => {
const { app, headers } = await fixture()
const from = '2026-01-01T00:00:00.000Z', to = '2026-01-01T01:00:00.000Z'
const query = `from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&resolution=1m&agent_ids=a`
expect((await app.inject({ url: `/api/v1/stats/timeseries?${query}`, headers: headers(['fw:agents:read']) })).statusCode).toBe(403)
const statsHeaders = headers(['fw:stats:read'])
const response = await app.inject({ url: `/api/v1/stats/timeseries?${query}`, headers: statsHeaders })
expect(response.statusCode).toBe(200)
expect(response.json().items).toHaveLength(60)
expect(response.json().items.every((item: { packets_dropped: unknown }) => item.packets_dropped === null)).toBe(true)
const csv = await app.inject({ url: `/api/v1/stats/timeseries.csv?${query}`, headers: statsHeaders })
expect(csv.statusCode).toBe(200)
expect(csv.headers['content-type']).toContain('text/csv')
expect(csv.body.split('\r\n')).toHaveLength(62)
for (const id of ['raw-a', 'raw-b', 'raw-c']) repos.insertStatsSample(app.db, { id, agentId: 'a', recordedAt: from, packetsDropped: 100 })
const path = `/api/v1/agents/a/stats/raw?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=2`
const page1 = await app.inject({ url: path, headers: statsHeaders })
expect(page1.statusCode).toBe(200)
expect(page1.json().items.map((item: { id: string }) => item.id)).toEqual(['raw-a', 'raw-b'])
expect(page1.json().has_more).toBe(true)
const page2 = await app.inject({ url: `${path}&cursor=${page1.json().next_cursor}`, headers: statsHeaders })
expect(page2.json().items.map((item: { id: string }) => item.id)).toEqual(['raw-c'])
expect(page2.json().next_cursor).toBeNull()
expect((await app.inject({ url: `${path}&cursor=invalid`, headers: statsHeaders })).statusCode).toBe(400)
expect((await app.inject({ url: `${path.replace('limit=2', 'limit=501')}`, headers: statsHeaders })).statusCode).toBe(400)
})
it('requires authentication/fw membership and independently omits unauthorized sections', async () => {
const { app, headers } = await fixture()
expect((await app.inject({ url: '/api/v1/ui/summary' })).statusCode).toBe(401)
expect((await app.inject({ url: '/api/v1/ui/summary', headers: headers([], []) })).statusCode).toBe(403)
const empty = await app.inject({ url: '/api/v1/ui/summary', headers: headers([]) })
expect(empty.statusCode).toBe(200)
expect(Object.keys(empty.json())).toEqual(['updated_at'])
for (const [scope, field] of [['agents', 'agents'], ['lists', 'lists'], ['policies', 'policies'], ['dashboard', 'dashboard']] as const) {
const response = await app.inject({ url: '/api/v1/ui/summary', headers: headers([`fw:${scope}:read`]) })
expect(response.statusCode).toBe(200)
expect(Object.keys(response.json()).sort()).toEqual([field, 'updated_at'].sort())
expect(uiSummarySchema.safeParse(response.json()).success).toBe(true)
}
const all = await app.inject({ url: '/api/v1/ui/summary', headers: headers(['fw:agents:write', 'fw:lists:write', 'fw:policies:write', 'fw:dashboard:read']) })
expect(all.json().agents).toEqual({ approved: 2, online: 1, pending: 1, offline: 1 })
expect(all.json().lists).toEqual({ total: 1, failed: 1 })
expect(all.json().dashboard.packets_dropped).toBe(15)
expect(all.body).not.toContain('token-a')
expect(all.body).not.toContain('upstream failed')
})
it('acknowledges a committed v2 report ID and preserves history/baselines through stats reset', async () => {
const { app, headers } = await fixture()
const post = async (sequence: number, value: number, reportId = `r-${sequence}`) => app.inject({
method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: 'Bearer token-a' },
payload: { telemetry_version: 2, report_id: reportId, sequence, boot_id: 'boot', observed_at: new Date().toISOString(), interval_ms: 60_000,
counter_epoch: { traffic: 'e' }, capabilities: ['policy_ipv4'], quality: { partial: false, truncated: false, unsupported: [], lost_reports: 0 }, status: 'ok', packets_dropped: value, packets_accepted: 0 },
})
const first = await post(1, 100)
expect(first.statusCode).toBe(200)
expect(first.json()).toMatchObject({ ok: true, accepted: true, report_id: 'r-1', sequence: 1 })
const replay = await post(1, 100)
expect(replay.json()).toMatchObject({ ok: true, duplicate: true, report_id: 'r-1' })
const reset = await app.inject({ method: 'POST', url: '/api/v1/agents/a/stats/reset', headers: headers(['fw:agents:write']) })
expect(reset.statusCode).toBe(200)
await post(2, 100)
expect(repos.getAgent(app.db, 'a')!.totalPacketsDropped).toBe(0)
await post(3, 105)
expect(repos.getAgent(app.db, 'a')!.totalPacketsDropped).toBe(5)
expect(repos.listStatsSamples(app.db, 'a')).toHaveLength(3)
const stale = await post(2, 999, 'restored-flash')
expect(stale.json()).toMatchObject({ ok: true, accepted: false, reason: 'stale_sequence', report_id: 'restored-flash', sequence: 2, sequence_floor: 3 })
expect(repos.getAgent(app.db, 'a')!.totalPacketsDropped).toBe(5)
app.sqlite.prepare('UPDATE agent_telemetry_state SET sequence=? WHERE agent_id=?').run(Number.MAX_SAFE_INTEGER, 'a')
expect((await post(1, 999, 'exhausted')).json().sequence_floor).toBe(Number.MAX_SAFE_INTEGER)
})
})
+6 -4
View File
@@ -1,6 +1,8 @@
/** Trim, drop empty, dedupe (order not guaranteed — sorted for stable hashes). */
import { canonicalCidr } from '@evofw/shared'
/** Validate, canonicalize and deduplicate networks for stable policy hashes. */
export function uniqCidrs(cidrs: readonly string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
return [...new Set(cidrs.map(canonicalCidr))].sort()
}
/** Trim, drop empty, dedupe preserving first-seen order. */
@@ -8,8 +10,8 @@ export function uniqCidrsPreserveOrder(cidrs: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of cidrs) {
const t = c.trim()
if (!t || seen.has(t)) continue
const t = canonicalCidr(c)
if (seen.has(t)) continue
seen.add(t)
out.push(t)
}
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
const dns = vi.hoisted(() => ({ lookup: vi.fn() }))
const https = vi.hoisted(() => ({ request: vi.fn() }))
vi.mock('node:dns/promises', () => dns)
vi.mock('node:https', () => https)
import { postWebhook } from './webhook-transport.js'
afterEach(() => vi.resetAllMocks())
describe('pinned webhook POST transport', () => {
it('rejects mixed DNS answers and rebinding on subsequent attempts without opening a socket', async () => {
dns.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }, { address: '127.0.0.1', family: 4 }])
await expect(postWebhook('https://hooks.example.com', 'secret', '{}', 'event', new AbortController().signal)).rejects.toThrow('INVALID_DESTINATION')
expect(https.request).not.toHaveBeenCalled()
dns.lookup.mockResolvedValue([{ address: '::ffff:10.0.0.1', family: 6 }])
await expect(postWebhook('https://hooks.example.com', 'secret', '{}', 'event', new AbortController().signal)).rejects.toThrow('INVALID_DESTINATION')
expect(dns.lookup).toHaveBeenCalledTimes(2)
})
it('pins the socket lookup, keeps TLS hostname, signs POST bytes and never follows redirects', async () => {
dns.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
const sent: string[] = []
https.request.mockImplementation((url, options, callback) => {
expect(url.hostname).toBe('hooks.example.com')
expect(options.method).toBe('POST')
expect(options.agent).toBe(false)
expect(options.rejectUnauthorized).not.toBe(false)
const selected = vi.fn()
options.lookup('hooks.example.com', {}, selected)
expect(selected).toHaveBeenCalledWith(null, '93.184.216.34', 4)
expect(options.headers['x-evofw-signature']).toMatch(/^v1=[a-f0-9]{64}$/)
expect(options.headers['x-evofw-event-id']).toBe('event')
const request = new EventEmitter() as EventEmitter & { end: (body: string) => void }
request.end = body => {
sent.push(body)
const response = Object.assign(new EventEmitter(), { statusCode: 302 })
callback(response)
queueMicrotask(() => response.emit('end'))
}
return request
})
await expect(postWebhook('https://hooks.example.com/path', 'secret', '{"id":1}', 'event', new AbortController().signal)).rejects.toThrow('HTTP_302')
expect(sent).toEqual(['{"id":1}'])
expect(https.request).toHaveBeenCalledTimes(1)
})
it('stops a DNS lookup when shutdown aborts', async () => {
dns.lookup.mockImplementation(() => new Promise(() => {}))
const controller = new AbortController()
const pending = postWebhook('https://hooks.example.com', 'secret', '{}', 'event', controller.signal)
controller.abort()
await expect(pending).rejects.toThrow('DELIVERY_TIMEOUT')
expect(https.request).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,43 @@
import { lookup } from 'node:dns/promises'
import { request } from 'node:https'
import { isIP, type LookupFunction } from 'node:net'
import { createHmac } from 'node:crypto'
import { isPublicSourceAddress } from './safe-fetch.js'
export function validateWebhookUrl(value: string): URL {
const url = new URL(value)
const host = url.hostname.replace(/^\[|\]$/g, '')
if (url.protocol !== 'https:' || url.username || url.password || url.hash || (url.port && url.port !== '443') || !host || (isIP(host) && !isPublicSourceAddress(host))) throw new Error('INVALID_DESTINATION')
return url
}
export function webhookSignature(secret: string, timestamp: string, body: string): string {
return `v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`
}
/** No redirects, proxy environment, shared sockets or configurable headers. */
export async function postWebhook(destination: string, secret: string, body: string, eventId: string, parentSignal: AbortSignal): Promise<void> {
const url = validateWebhookUrl(destination)
const signal = AbortSignal.any([parentSignal, AbortSignal.timeout(8000)])
const host = url.hostname.replace(/^\[|\]$/g, '')
const addresses = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
const aborted = () => reject(new Error('DELIVERY_TIMEOUT'))
signal.addEventListener('abort', aborted, { once: true })
if (signal.aborted) aborted()
lookup(host, { all: true, verbatim: true }).then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted))
})
if (!addresses.length || addresses.some(item => !isPublicSourceAddress(item.address))) throw new Error('INVALID_DESTINATION')
const chosen = addresses[0]!
const timestamp = String(Math.floor(Date.now() / 1000))
const pinned = ((_hostname: string, _options: unknown, done: (error: Error | null, address: string, family: number) => void) => done(null, chosen.address, chosen.family)) as LookupFunction
await new Promise<void>((resolve, reject) => {
const req = request(url, { method: 'POST', agent: false, family: chosen.family, lookup: pinned, signal,
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-evofw-event-id': eventId, 'x-evofw-timestamp': timestamp, 'x-evofw-signature': webhookSignature(secret, timestamp, body) },
}, response => {
let bytes = 0
response.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 65536) response.destroy(new Error('RESPONSE_TOO_LARGE')) })
response.on('error', reject)
response.on('end', () => response.statusCode && response.statusCode >= 200 && response.statusCode < 300 ? resolve() : reject(new Error(`HTTP_${response.statusCode ?? 0}`)))
})
req.on('error', reject)
req.end(body)
})
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
const body = { name: 'test', url: 'https://hooks.example.com', secret: 's'.repeat(32), events: ['source.error'] }
describe('webhook routes', () => {
it('requires settings admin and redacts secrets from all endpoint responses', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: true, secretKey: 'k'.repeat(32), staticDir: null, logLevel: 'silent' } })
try {
const token = (permissions: string[]) => app.jwt.sign({ sub: 'tester', apps: ['fw'], permissions, iss: loadConfig().authIssuer })
const reader = { authorization: `Bearer ${token(['fw:settings:read'])}` }
expect((await app.inject({ url: '/api/v1/webhooks', headers: reader })).statusCode).toBe(403)
const admin = { authorization: `Bearer ${token(['fw:settings:admin'])}` }
const created = await app.inject({ method: 'POST', url: '/api/v1/webhooks', headers: admin, payload: body })
expect(created.statusCode).toBe(201)
expect(created.body).not.toContain(body.secret)
expect(created.json().secret_configured).toBe(true)
expect((await app.inject({ url: '/api/v1/webhooks', headers: admin })).body).not.toContain(body.secret)
expect((await app.inject({ url: `/api/v1/webhooks/${created.json().id}/deliveries`, headers: admin })).json()).toEqual({ items: [], total: 0 })
} finally { await app.close() }
})
it('returns a clear 503 on create/update without storage key', async () => {
const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, secretKey: null, staticDir: null, logLevel: 'silent' } })
try {
const result = await app.inject({ method: 'POST', url: '/api/v1/webhooks', payload: body })
expect(result.statusCode).toBe(503)
expect(result.json().error.code).toBe('WEBHOOK_KEY_REQUIRED')
expect((await app.inject({ method: 'PATCH', url: '/api/v1/webhooks/unknown', payload: { name: 'new' } })).statusCode).toBe(503)
} finally { await app.close() }
})
})
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest'
import { createMemoryDb, runMigrations } from '@evofw/db'
import { Webhooks } from './webhooks.js'
import { loadConfig } from '../config.js'
import { validateWebhookUrl, webhookSignature, postWebhook } from './webhook-transport.js'
import type { WebhookBody } from '@evofw/shared'
const body: WebhookBody = { name: 'Alerts', url: 'https://hooks.example.com/events', secret: 's'.repeat(32), events: ['agent.offline', 'source.error'], enabled: true }
function fixture(key: string | null = 'k'.repeat(32), transport = vi.fn(async (..._args: Parameters<typeof postWebhook>) => {})) {
const { sqlite } = createMemoryDb(); runMigrations(sqlite)
const worker = new Webhooks(sqlite, { ...loadConfig(), secretKey: key }, transport)
return { sqlite, worker, transport }
}
describe('durable webhooks', () => {
it.each(['destination', 'disable'] as const)('does not send cached canceled deliveries after endpoint %s changes during an active request', async change => {
let entered!: () => void, finish!: () => void
const started = new Promise<void>(resolve => { entered = resolve })
const paused = new Promise<void>(resolve => { finish = resolve })
const transport = vi.fn(async (..._args: Parameters<typeof postWebhook>) => { entered(); await paused })
const { sqlite, worker } = fixture('k'.repeat(32), transport)
const now = Date.now()
try {
const endpoint = worker.create(body)
for (let i = 0; i < 3; i++) worker.observe('agent.offline', `queued-${i}`, true, new Date(now).toISOString(), now)
const active = worker.tick(now)
await started
worker.update(endpoint.id, change === 'disable' ? { enabled: false } : { url: 'https://new.example.com', secret: 'new'.repeat(16) })
finish()
await active
expect(transport).toHaveBeenCalledTimes(1)
expect(transport.mock.calls[0]?.[0]).toBe(body.url)
expect(worker.stats().pending).toBe(0)
} finally { finish(); await worker.close(); sqlite.close() }
})
it('requires a strong configured encryption key and never returns/stores plaintext secrets', async () => {
const { sqlite, worker } = fixture()
try {
const endpoint = worker.create(body)
expect(JSON.stringify(endpoint)).not.toContain(body.secret)
const cipher = sqlite.prepare('SELECT secret_cipher FROM webhook_endpoints').get() as { secret_cipher: string }
expect(cipher.secret_cipher).not.toContain(body.secret)
const missing = new Webhooks(sqlite, { ...loadConfig(), secretKey: null })
expect(() => missing.create(body)).toThrow('EVOFW_SECRET_KEY')
expect(() => missing.update(endpoint.id, { name: 'new' })).toThrow('EVOFW_SECRET_KEY')
expect(missing.list().configured).toBe(false)
const weak = new Webhooks(sqlite, { ...loadConfig(), secretKey: 'short' })
expect(() => weak.create(body)).toThrow('EVOFW_SECRET_KEY')
} finally { await worker.close(); sqlite.close() }
})
it('persists exact retry bytes, cooldown transitions and recovery across worker restart', async () => {
const failing = vi.fn(async (..._args: Parameters<typeof postWebhook>) => { throw new Error('https://secret:password@internal/private') })
const { sqlite, worker } = fixture('k'.repeat(32), failing)
const now = Date.now()
try {
const endpoint = worker.create(body)
// Use a source row so bounded scan preserves the transition across restart.
sqlite.prepare("INSERT INTO ip_lists(id,name,type,last_error,updated_at) VALUES ('source','Source','json_url','private failure',?)").run(new Date(now).toISOString())
await worker.tick(now)
expect(worker.stats().pending).toBe(1)
expect((worker.deliveries(endpoint.id).items[0] as { last_error: string }).last_error).toBe('DELIVERY_FAILED')
await worker.close()
const succeeds = vi.fn(async (..._args: Parameters<typeof postWebhook>) => {})
const restarted = new Webhooks(sqlite, { ...loadConfig(), secretKey: 'k'.repeat(32) }, succeeds)
await restarted.tick(now + 10_000)
expect(succeeds).toHaveBeenCalledTimes(1)
expect(succeeds.mock.calls[0]?.slice(2, 4)).toEqual(failing.mock.calls[0]?.slice(2, 4))
expect(restarted.stats().pending).toBe(0)
sqlite.prepare("UPDATE ip_lists SET last_error=NULL,refreshed_at=? WHERE id='source'").run(new Date(now + 20_000).toISOString())
await restarted.tick(now + 20_000)
expect(succeeds).toHaveBeenCalledTimes(1)
await restarted.tick(now + 301_000)
expect(succeeds).toHaveBeenCalledTimes(2)
expect(JSON.parse(String(succeeds.mock.calls[1]?.[2])).state).toBe('healthy')
await restarted.close()
} finally { await worker.close(); sqlite.close() }
})
it('bounds endpoints/outbox/deadletters and cancels queued deliveries on rotation/delete', async () => {
const { sqlite, worker } = fixture()
const now = Date.now()
try {
const endpoint = worker.create(body)
for (let i = 0; i < 1002; i++) worker.observe('agent.offline', `a${i}`, true, new Date(now).toISOString(), now)
expect(worker.stats()).toEqual({ pending: 1000, dead: 0, dropped: 2 })
worker.update(endpoint.id, { secret: 'n'.repeat(32) })
expect(worker.stats().pending).toBe(0)
for (let i = 0; i < 19; i++) worker.create({ ...body, name: `endpoint${i}` })
expect(() => worker.create(body)).toThrow('At most 20')
worker.observe('agent.offline', 'test', true, new Date(now).toISOString(), now)
worker.remove(endpoint.id)
expect(sqlite.prepare('SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id=?').get(endpoint.id)).toEqual({ n: 0 })
} finally { await worker.close(); sqlite.close() }
})
it('dead-letters expired delivery and retains at most 100 terminal rows', async () => {
const transport = vi.fn(async (..._args: Parameters<typeof postWebhook>) => { throw new Error('failure') })
const { sqlite, worker } = fixture('k'.repeat(32), transport)
const now = Date.now()
try {
worker.create(body)
for (let i = 0; i < 110; i++) worker.observe('agent.offline', `a${i}`, true, new Date(now).toISOString(), now)
sqlite.prepare("UPDATE webhook_deliveries SET status='dead'").run()
await worker.tick(now)
expect(worker.stats().dead).toBe(100)
expect(transport).not.toHaveBeenCalled()
} finally { await worker.close(); sqlite.close() }
})
})
describe('webhook signing and destination policy', () => {
it('rejects local/private/mapped destinations, non-HTTPS, non443 and credentials', () => {
for (const url of ['http://example.com', 'https://127.0.0.1', 'https://[::1]', 'https://[::ffff:127.0.0.1]', 'https://10.0.0.1', 'https://user:secret@example.com', 'https://example.com:8443', 'https://example.com/#token']) expect(() => validateWebhookUrl(url)).toThrow()
expect(validateWebhookUrl(body.url).hostname).toBe('hooks.example.com')
expect(webhookSignature('secret', '100', '{}')).toMatch(/^v1=[0-9a-f]{64}$/)
expect(webhookSignature('secret', '100', '{}')).not.toBe(webhookSignature('secret', '101', '{}'))
})
})
+161
View File
@@ -0,0 +1,161 @@
import type { Sqlite } from '@evofw/db'
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto'
import type { AppConfig } from '../config.js'
import { AppError } from '../plugins/error-handler.js'
import { postWebhook, validateWebhookUrl } from './webhook-transport.js'
import type { WebhookBody, WebhookEndpoint, WebhookEventType } from '@evofw/shared'
type EndpointRow = { id: string; name: string; url: string; enabled: number; generation: number; events_json: string; secret_cipher: string; created_at: string; updated_at: string }
type DeliveryRow = { id: string; endpoint_id: string; payload_json: string; attempts: number; created_at: string }
type Transport = typeof postWebhook
const COOLDOWN = 300_000
export const WEBHOOK_LIMITS = { endpoints: 20, pending: 1000 }
/** Separate from legacy optional secret encryption: missing/weak keys fail closed. */
export class Webhooks {
private running: Promise<void> | null = null
private abort = new AbortController()
private agentCursor = ''
private sourceCursor = ''
private droppedWarningAt = 0
constructor(private db: Sqlite, private config: AppConfig, private transport: Transport = postWebhook, private warning: () => void = () => {}) {}
configured(): boolean { return !!this.config.secretKey && this.config.secretKey.length >= 32 }
private key(): Buffer {
if (!this.configured()) throw new AppError('WEBHOOK_KEY_REQUIRED', 'Set EVOFW_SECRET_KEY to at least 32 characters before managing webhook endpoints', 503)
return createHash('sha256').update(this.config.secretKey!).digest()
}
private encrypt(secret: string, id: string): string {
const nonce = randomBytes(12), cipher = createCipheriv('aes-256-gcm', this.key(), nonce)
cipher.setAAD(Buffer.from(id))
const data = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()])
return ['v1', nonce.toString('base64'), cipher.getAuthTag().toString('base64'), data.toString('base64')].join(':')
}
private decrypt(value: string, id: string): string {
const [version, nonce, tag, data] = value.split(':')
if (version !== 'v1' || !nonce || !tag || !data) throw new Error('KEY_UNAVAILABLE')
const cipher = createDecipheriv('aes-256-gcm', this.key(), Buffer.from(nonce, 'base64'))
cipher.setAAD(Buffer.from(id)); cipher.setAuthTag(Buffer.from(tag, 'base64'))
return Buffer.concat([cipher.update(Buffer.from(data, 'base64')), cipher.final()]).toString('utf8')
}
private row(id: string): EndpointRow {
const row = this.db.prepare('SELECT * FROM webhook_endpoints WHERE id=?').get(id) as EndpointRow | undefined
if (!row) throw new AppError('NOT_FOUND', 'Webhook not found', 404)
return row
}
private dto(row: EndpointRow): WebhookEndpoint {
return { id: row.id, name: row.name, url: row.url, enabled: !!row.enabled, events: JSON.parse(row.events_json), secret_configured: true, created_at: row.created_at, updated_at: row.updated_at }
}
list() { return { items: (this.db.prepare('SELECT * FROM webhook_endpoints ORDER BY created_at,id LIMIT 20').all() as EndpointRow[]).map(row => this.dto(row)), configured: this.configured(), limits: WEBHOOK_LIMITS, queue: this.stats() } }
stats() {
const count = (state: string) => (this.db.prepare('SELECT count(*) AS n FROM webhook_deliveries WHERE status=?').get(state) as { n: number }).n
return { pending: count('pending'), dead: count('dead'), dropped: (this.db.prepare('SELECT dropped FROM webhook_metrics WHERE id=1').get() as { dropped: number }).dropped }
}
create(body: WebhookBody) {
this.key()
try { validateWebhookUrl(body.url) } catch { throw new AppError('VALIDATION_ERROR', 'Webhook requires a public HTTPS destination on port 443 without credentials or fragments', 400) }
const id = randomUUID(), now = new Date().toISOString()
this.db.transaction(() => {
if ((this.db.prepare('SELECT count(*) AS n FROM webhook_endpoints').get() as { n: number }).n >= WEBHOOK_LIMITS.endpoints) throw new AppError('WEBHOOK_LIMIT', 'At most 20 webhook endpoints', 409)
this.db.prepare('INSERT INTO webhook_endpoints(id,name,url,secret_cipher,enabled,events_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)').run(id, body.name, body.url, this.encrypt(body.secret, id), Number(body.enabled), JSON.stringify([...new Set(body.events)]), now, now)
})()
return this.dto(this.row(id))
}
update(id: string, body: Partial<WebhookBody>) {
this.key()
const row = this.row(id)
if (body.url !== undefined) try { validateWebhookUrl(body.url) } catch { throw new AppError('VALIDATION_ERROR', 'Invalid webhook destination', 400) }
this.db.transaction(() => {
// Changing delivery destination/key/subscriptions cancels pending bytes.
const deliveryChanged = body.secret !== undefined || body.url !== undefined || body.events !== undefined || body.enabled !== undefined
if (deliveryChanged) {
this.db.prepare('DELETE FROM webhook_deliveries WHERE endpoint_id=?').run(id)
this.db.prepare('UPDATE webhook_endpoints SET generation=generation+1 WHERE id=?').run(id)
}
if (body.events !== undefined || body.enabled === false) this.db.prepare('DELETE FROM webhook_transitions WHERE endpoint_id=?').run(id)
this.db.prepare('UPDATE webhook_endpoints SET name=?,url=?,secret_cipher=?,enabled=?,events_json=?,updated_at=? WHERE id=?').run(body.name ?? row.name, body.url ?? row.url, body.secret === undefined ? row.secret_cipher : this.encrypt(body.secret, id), body.enabled === undefined ? row.enabled : Number(body.enabled), body.events === undefined ? row.events_json : JSON.stringify([...new Set(body.events)]), new Date().toISOString(), id)
})()
return this.dto(this.row(id))
}
remove(id: string) { this.row(id); this.db.prepare('DELETE FROM webhook_endpoints WHERE id=?').run(id); return { ok: true } }
deliveries(id: string) {
this.row(id)
return { items: this.db.prepare('SELECT id,event_type,resource_id,status,attempts,next_attempt_at,created_at,last_error FROM webhook_deliveries WHERE endpoint_id=? ORDER BY created_at DESC,id LIMIT 100').all(id), total: (this.db.prepare('SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id=?').get(id) as { n: number }).n }
}
/** Transition and event bytes are committed together, independent of ingestion. */
observe(eventType: WebhookEventType, resourceId: string, problem: boolean, observedAt: string, now = Date.now()) {
const endpoints = this.db.prepare('SELECT * FROM webhook_endpoints WHERE enabled=1 LIMIT 20').all() as EndpointRow[]
for (const endpoint of endpoints) {
if (!(JSON.parse(endpoint.events_json) as string[]).includes(eventType)) continue
this.db.transaction(() => {
const previous = this.db.prepare('SELECT state,last_emitted_at FROM webhook_transitions WHERE endpoint_id=? AND event_type=? AND resource_id=?').get(endpoint.id, eventType, resourceId) as { state: string; last_emitted_at: string | null } | undefined
const state = problem ? 'problem' : 'healthy'
if (previous?.state === state || (previous?.last_emitted_at && now - Date.parse(previous.last_emitted_at) < COOLDOWN)) return
const initialHealthy = !previous && !problem
const created = new Date(now).toISOString()
if (!initialHealthy) {
const id = randomUUID()
const payload = JSON.stringify({ version: 1, id, type: eventType, resource_id: resourceId, state, occurred_at: observedAt })
if (this.stats().pending >= WEBHOOK_LIMITS.pending || Buffer.byteLength(payload) > 16384) {
this.db.prepare('UPDATE webhook_metrics SET dropped=dropped+1 WHERE id=1').run()
if (now - this.droppedWarningAt >= COOLDOWN) { this.warning(); this.droppedWarningAt = now }
} else this.db.prepare('INSERT INTO webhook_deliveries(id,endpoint_id,event_type,resource_id,payload_json,endpoint_generation,next_attempt_at,created_at) VALUES (?,?,?,?,?,?,?,?)').run(id, endpoint.id, eventType, resourceId, payload, endpoint.generation, created, created)
}
this.db.prepare('INSERT INTO webhook_transitions(endpoint_id,event_type,resource_id,state,last_emitted_at) VALUES (?,?,?,?,?) ON CONFLICT(endpoint_id,event_type,resource_id) DO UPDATE SET state=excluded.state,last_emitted_at=excluded.last_emitted_at').run(endpoint.id, eventType, resourceId, state, initialHealthy ? null : created)
})()
}
}
private scan(now: number) {
type Agent = { id: string; last_seen_at: string | null; last_apply_at: string | null; last_apply_status: string | null; desired_hash: string | null; applied_hash: string | null; desired_hash_generation: number | null; policy_generation: number; applied_revision: number | null }
const agents = this.db.prepare("SELECT id,last_seen_at,last_apply_at,last_apply_status,desired_hash,applied_hash,desired_hash_generation,policy_generation,applied_revision FROM agents WHERE status='approved' AND id>? ORDER BY id LIMIT 100").all(this.agentCursor) as Agent[]
this.agentCursor = agents.length === 100 ? agents.at(-1)!.id : ''
for (const agent of agents) {
// A never-seen invited/approved device is unknown, not an offline incident.
if (agent.last_seen_at) this.observe('agent.offline', agent.id, now - Date.parse(agent.last_seen_at) > 180_000, agent.last_seen_at, now)
if (agent.last_apply_at) this.observe('agent.apply_failed', agent.id, agent.last_apply_status === 'error' || agent.last_apply_status === 'failed', agent.last_apply_at, now)
if (agent.applied_hash && agent.desired_hash && agent.desired_hash_generation === agent.policy_generation) this.observe('agent.drift', agent.id, agent.applied_revision === agent.policy_generation && agent.applied_hash !== agent.desired_hash, agent.last_apply_at ?? new Date(now).toISOString(), now)
}
const sources = this.db.prepare("SELECT id,refreshed_at,last_error,updated_at FROM ip_lists WHERE type<>'static' AND id>? ORDER BY id LIMIT 100").all(this.sourceCursor) as { id: string; refreshed_at: string | null; last_error: string | null; updated_at: string }[]
this.sourceCursor = sources.length === 100 ? sources.at(-1)!.id : ''
for (const source of sources) if (source.last_error || source.refreshed_at) this.observe('source.error', source.id, !!source.last_error, source.last_error ? source.updated_at : source.refreshed_at!, now)
this.db.prepare("DELETE FROM webhook_transitions WHERE (event_type LIKE 'agent.%' AND resource_id NOT IN (SELECT id FROM agents WHERE status='approved')) OR (event_type='source.error' AND resource_id NOT IN (SELECT id FROM ip_lists WHERE type<>'static'))").run()
}
tick(now = Date.now()): Promise<void> {
if (this.running) return this.running
if (this.abort.signal.aborted || !this.configured()) return Promise.resolve()
this.running = this.run(now).finally(() => { this.running = null })
return this.running
}
private async run(now: number) {
this.scan(now)
const rows = this.db.prepare("SELECT d.* FROM webhook_deliveries d JOIN webhook_endpoints e ON e.id=d.endpoint_id WHERE d.status='pending' AND d.next_attempt_at<=? AND e.enabled=1 ORDER BY d.next_attempt_at,d.id LIMIT 5").all(new Date(now).toISOString()) as DeliveryRow[]
for (const candidate of rows) {
if (this.abort.signal.aborted) break
// Earlier awaits allow an administrator to cancel/change an endpoint.
// Re-read eligibility and generation immediately before each request;
// cached batch payloads must never move to a new destination or secret.
const row = this.db.prepare("SELECT d.* FROM webhook_deliveries d JOIN webhook_endpoints e ON e.id=d.endpoint_id WHERE d.id=? AND d.status='pending' AND e.enabled=1 AND d.endpoint_generation=e.generation").get(candidate.id) as DeliveryRow | undefined
if (!row) continue
let error: string | null = null
if (now - Date.parse(row.created_at) >= 86400_000 || row.attempts >= 12) {
this.db.prepare("UPDATE webhook_deliveries SET status='dead',last_error='EXPIRED' WHERE id=?").run(row.id)
continue
}
try {
const endpoint = this.row(row.endpoint_id)
await this.transport(endpoint.url, this.decrypt(endpoint.secret_cipher, endpoint.id), row.payload_json, row.id, this.abort.signal)
} catch (caught) {
const message = caught instanceof Error ? caught.message : ''
error = /^(HTTP_[0-9]{3}|INVALID_DESTINATION|RESPONSE_TOO_LARGE|KEY_UNAVAILABLE)$/.test(message) ? message : 'DELIVERY_FAILED'
}
if (this.abort.signal.aborted) break
if (!error) this.db.prepare('DELETE FROM webhook_deliveries WHERE id=?').run(row.id)
else {
const attempts = row.attempts + 1
const wait = Math.min(3600_000, 5000 * 2 ** (attempts - 1)) * (0.8 + Math.random() * 0.4)
this.db.prepare('UPDATE webhook_deliveries SET attempts=?,status=?,next_attempt_at=?,last_error=? WHERE id=?').run(attempts, attempts >= 12 ? 'dead' : 'pending', new Date(now + wait).toISOString(), error, row.id)
}
}
this.db.prepare("DELETE FROM webhook_deliveries WHERE status='dead' AND id NOT IN (SELECT id FROM webhook_deliveries WHERE status='dead' ORDER BY created_at DESC,id LIMIT 100)").run()
}
async close() { this.abort.abort(); await this.running }
}
@@ -1,3 +1,4 @@
import { invalidateAfterMutation } from '@/queries/invalidation'
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
@@ -83,7 +84,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
onSuccess: (link) => {
setCreated(link)
toast.success('Агент создан')
void qc.invalidateQueries({ queryKey: ['agents'] })
void invalidateAfterMutation(qc, 'agent')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -101,12 +102,13 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
<SheetHeader className="shrink-0">
<SheetTitle>Команда установки</SheetTitle>
<SheetDescription>
Агент уже в списке (Invited). Скопируйте one-liner и выполните на
хосте.
Агент уже в списке (Invited). Ссылка одноразовая, действует 24 часа и
погашается при регистрации. Выполните команду на целевом хосте.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
<p className="text-muted-foreground text-sm">Истекает: {created.expires_at ? new Date(created.expires_at).toLocaleString() : 'неизвестно'}. Повторная установка по использованной ссылке недоступна.</p>
<Field>
<FieldLabel>По id</FieldLabel>
<div className="flex flex-col gap-2">
@@ -167,7 +169,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
open={open}
onOpenChange={onOpenChange}
title="Добавить агента"
description="Создайте агента и короткую install-ссылку."
description="Создайте агента и одноразовую install-ссылку на 24 часа."
form={form}
onSubmit={async (values) => {
await create.mutateAsync(values)
@@ -1,3 +1,4 @@
import { QueryState } from '@/components/query-state'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
@@ -137,6 +138,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<QueryState isLoading={q.isPending} isError={q.isError} error={q.error} onRetry={() => void q.refetch()}>
{q.isLoading ? (
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-8 w-full" />
@@ -172,6 +174,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
<DataGridTable />
</DataGrid>
)}
</QueryState>
</FramePanel>
</Frame>
)
@@ -1,3 +1,4 @@
import { QueryState } from '@/components/query-state'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
@@ -118,6 +119,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<QueryState isLoading={q.isPending} isError={q.isError} error={q.error} onRetry={() => void q.refetch()}>
{q.isLoading ? (
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-8 w-full" />
@@ -149,6 +151,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
<DataGridTable />
</DataGrid>
)}
</QueryState>
</FramePanel>
</Frame>
)
@@ -0,0 +1,278 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
import {
adminCredentialStatusSchema,
installLinkSchema,
type InstallLink,
} from '@evofw/shared'
import { FormSheet } from '@/components/form-sheet'
import { QueryState } from '@/components/query-state'
import { ResourcePage } from '@/components/reui-kit'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Alert, AlertTitle, AlertDescription } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { formatDateTime } from '@/lib/format'
import { Button } from '@evofw/ui/components/button'
export function installLinkState(
link: Pick<InstallLink, 'revoked_at' | 'consumed_at' | 'expires_at'>,
// Lifecycle expiry is wall-clock state, not a generated design value.
now = Date.now(),
) {
if (link.revoked_at) return 'Отозвана'
if (link.consumed_at) return 'Использована'
if (link.expires_at && !Number.isFinite(Date.parse(link.expires_at)))
return 'Срок неизвестен'
if (!link.expires_at || Date.parse(link.expires_at) <= now) return 'Истекла'
return 'Ожидает установки'
}
export function AgentCredentialsSheet({
agentId,
open,
onOpenChange,
}: {
agentId: string
open: boolean
onOpenChange: (value: boolean) => void
}) {
const can = useCan()
const read = can('fw:agents:read')
const write = can('fw:agents:write')
const qc = useQueryClient()
const form = useForm<Record<string, never>>()
const [confirm, setConfirm] = useState(false)
const statusKey = ['agents', agentId, 'token-rotation']
const status = useQuery({
queryKey: statusKey,
queryFn: async ({ signal }) =>
adminCredentialStatusSchema.parse(
await apiFetch(`/api/v1/agents/${agentId}/token-rotation`, { signal }),
),
enabled: open && read,
refetchInterval: open ? 10_000 : false,
})
const links = useQuery({
queryKey: ['install-links', 'metadata', agentId],
queryFn: async ({ signal }) => {
const schema = z.object({
items: z.array(installLinkSchema),
total: z.number(),
})
const response = schema.parse(
await apiFetch('/api/v1/install-links?limit=100', { signal }),
)
const items = [...response.items]
for (
let offset = 100;
offset < Math.min(response.total, 10_000);
offset += 100
)
items.push(
...schema.parse(
await apiFetch(`/api/v1/install-links?limit=100&offset=${offset}`, {
signal,
}),
).items,
)
// Keep only lifecycle metadata in the query cache; installation commands and URLs are not needed here.
return items
.filter((item) => item.agent_id === agentId)
.map(
({
created_at,
expires_at,
consumed_at,
revoked_at,
last_used_at,
use_count,
id,
}) => ({
created_at,
expires_at,
consumed_at,
revoked_at,
last_used_at,
use_count,
id,
}),
)
},
enabled: open && read,
})
const rotate = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/token-rotation`, { method: 'POST' }),
onSuccess: () => {
toast.success('Ротация запрошена; ожидается агент')
void qc.invalidateQueries({ queryKey: statusKey })
},
onError: (e: Error) => {
toast.error(e.message)
void status.refetch()
},
})
const current = status.data
const pending = Boolean(
// The request expiry changes whether a new rotation may be requested.
current?.rotation && Date.parse(current.rotation.expires_at) > Date.now(),
)
return (
<>
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Учётные данные агента"
description="Ротацию выполняет сам агент. Панель показывает только состояние и сроки."
form={form}
onSubmit={() => {}}
size="lg"
footer={
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Закрыть
</Button>
}
>
{!read ? (
<Alert>
<AlertTitle>Нет доступа к агентам</AlertTitle>
</Alert>
) : (
<>
<QueryState
isLoading={status.isLoading}
isError={status.isError}
hasData={Boolean(current)}
error={status.error}
onRetry={() => void status.refetch()}
>
{current && (
<>
<Badge
variant={
pending
? 'warning-light'
: current.completed_rotation
? 'success-light'
: 'info-light'
}
>
{pending
? 'Ожидается обмен ключа агентом'
: current.rotation
? 'Запрос ротации истёк'
: current.completed_rotation
? 'Ротация завершена'
: 'Нет активного запроса'}
</Badge>
{current.rotation && (
<p className="text-sm">
Срок запроса:{' '}
{formatDateTime(current.rotation.expires_at)}
</p>
)}
{!current.supported && (
<Alert variant="warning">
<AlertTitle>Ротация не поддерживается</AlertTitle>
<AlertDescription>
Обновите агент до версии с поддержкой scoped enrollment
и rotation.
</AlertDescription>
</Alert>
)}
{current.completed_rotation && (
<p className="text-muted-foreground text-sm">
Завершён обмен credentials. Это не подтверждение
применения firewall policy. Новый запрос может быть
временно заблокирован окном восстановления агента.
</p>
)}
{write && (
<Button
type="button"
variant="outline"
disabled={
rotate.isPending ||
pending ||
!current.supported ||
current.agent_status !== 'approved' ||
status.isError
}
onClick={() => setConfirm(true)}
>
Запросить ротацию
</Button>
)}
</>
)}
</QueryState>
<QueryState
isLoading={links.isLoading}
isError={links.isError}
error={links.error}
onRetry={() => void links.refetch()}
>
<ResourcePage
title="Одноразовые ссылки установки"
data={links.data ?? []}
getRowId={(r) => r.id}
columns={[
{
id: 'state',
header: 'Состояние',
cell: ({ row }) => installLinkState(row.original),
},
{
id: 'expiry',
header: 'Истекает',
cell: ({ row }) => formatDateTime(row.original.expires_at),
},
{
id: 'consumed',
header: 'Использована',
cell: ({ row }) => formatDateTime(row.original.consumed_at),
},
{
id: 'revoked',
header: 'Отозвана',
cell: ({ row }) => formatDateTime(row.original.revoked_at),
},
]}
filterFields={[]}
filters={[]}
onFiltersChange={() => {}}
getFilterFieldValue={() => undefined}
emptyState={{
title: 'Нет ссылок установки',
description:
'Ротация существующего агента не требует новой install-ссылки.',
}}
/>
</QueryState>
</>
)}
</FormSheet>
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
title="Запросить ротацию credentials?"
description="При следующей синхронизации поддерживаемый агент выполнит обмен и сохранит новый ключ. Запрос не означает завершённую ротацию."
confirmLabel="Запросить"
confirmVariant="default"
onConfirm={() => {
rotate.mutate()
setConfirm(false)
}}
/>
</>
)
}
@@ -1,7 +1,8 @@
import { invalidateAfterMutation } from '@/queries/invalidation'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
import { useState } from 'react'
import { lazy, Suspense, useState } from 'react'
import { ActivityIcon, ArrowLeftIcon, CircleAlertIcon, ClockIcon, CpuIcon, ShieldIcon } from 'lucide-react'
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
@@ -13,13 +14,14 @@ import { agentTrafficAccepted, agentTrafficDropped } from '@/components/agents/a
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentDiagnosticsPanel } from '@/components/agents/agent-diagnostics-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { AgentCloneSetsSheet, AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
const AgentBlockedIps = lazy(() => import('./agent-blocked-ips').then((module) => ({ default: module.AgentBlockedIps })))
const AgentBlockedPorts = lazy(() => import('./agent-blocked-ports').then((module) => ({ default: module.AgentBlockedPorts })))
const AgentHostFirewall = lazy(() => import('./agent-host-firewall').then((module) => ({ default: module.AgentHostFirewall })))
const AgentPortAcl = lazy(() => import('./agent-port-acl').then((module) => ({ default: module.AgentPortAcl })))
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { agentPreviewQueryOptions, agentQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { formatDateTime, formatNumber, formatRelativeTime } from '@/lib/format'
@@ -29,6 +31,16 @@ import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
const loadExplainSheet = () => import('./agent-explain-sheet')
const loadSettingsSheets = () => import('./agent-settings-sheets')
const loadLabels = () => import('./agent-labels-sheet')
const loadCredentials = () => import('./agent-credentials-sheet')
const AgentCredentialsSheet = lazy(() => loadCredentials().then((module) => ({ default: module.AgentCredentialsSheet })))
const AgentLabelsSheet = lazy(() => loadLabels().then((module) => ({ default: module.AgentLabelsSheet })))
const AgentExplainSheet = lazy(() => loadExplainSheet().then((module) => ({ default: module.AgentExplainSheet })))
const AgentOverrideSheet = lazy(() => loadSettingsSheets().then((module) => ({ default: module.AgentOverrideSheet })))
const AgentCloneSetsSheet = lazy(() => loadSettingsSheets().then((module) => ({ default: module.AgentCloneSetsSheet })))
type Confirm = 'revoke' | 'delete' | 'reset' | null
/**
@@ -45,19 +57,27 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
const [tab, setTab] = useState('policy')
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const [explainOpen, setExplainOpen] = useState(false)
const [labelsOpen, setLabelsOpen] = useState(false)
const [labelsMounted, setLabelsMounted] = useState(false)
const [credentialsOpen, setCredentialsOpen] = useState(false)
const [credentialsMounted, setCredentialsMounted] = useState(false)
const [mountedForms, setMountedForms] = useState({ override: false, clone: false, explain: false })
const showSheet = (kind: keyof typeof mountedForms) => {
setMountedForms((previous) => ({ ...previous, [kind]: true }))
if (kind === 'override') setOverrideOpen(true)
else if (kind === 'clone') setCloneOpen(true)
else setExplainOpen(true)
}
const [confirm, setConfirm] = useState<Confirm>(null)
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
}
const fail = (e: Error) => toast.error(e.message)
const approve = useMutation({
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
invalidate()
void invalidateAfterMutation(qc, 'agent')
},
onError: fail,
})
@@ -65,7 +85,7 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
invalidate()
void invalidateAfterMutation(qc, 'agent')
},
onError: fail,
})
@@ -73,12 +93,7 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
onSuccess: () => {
toast.success('Статистика сброшена')
invalidate()
for (const key of ['stats', 'blocked-ips', 'blocked-ports']) {
void qc.invalidateQueries({ queryKey: ['agents', agentId, key] })
}
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
void invalidateAfterMutation(qc, 'agent')
},
onError: fail,
})
@@ -86,8 +101,8 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Агент удалён')
void qc.invalidateQueries({ queryKey: ['agents'] })
void navigate({ to: '/agents', search: { view: 'cards' } })
void invalidateAfterMutation(qc, 'agent')
},
onError: fail,
})
@@ -168,12 +183,15 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
<AgentOnlineDot agent={a} />
<StatusBadge status={a.status} />
</span>
<Button type="button" variant="outline" onFocus={() => void loadExplainSheet()} onPointerEnter={() => void loadExplainSheet()} onClick={() => showSheet('explain')}>Почему принято решение</Button>
<Button type="button" variant="outline" onFocus={() => void loadLabels()} onPointerEnter={() => void loadLabels()} onClick={() => { setLabelsMounted(true); setLabelsOpen(true) }}>Метки</Button>
<Button type="button" variant="outline" onFocus={() => void loadCredentials()} onPointerEnter={() => void loadCredentials()} onClick={() => { setCredentialsMounted(true); setCredentialsOpen(true) }}>Учётные данные</Button>
{canWrite ? (
<>
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
<Button type="button" variant="outline" onFocus={() => void loadSettingsSheets()} onPointerEnter={() => void loadSettingsSheets()} onClick={() => showSheet('override')}>
IP override
</Button>
<Button variant="outline" onClick={() => setCloneOpen(true)}>
<Button type="button" variant="outline" onFocus={() => void loadSettingsSheets()} onPointerEnter={() => void loadSettingsSheets()} onClick={() => showSheet('clone')}>
Копировать наборы
</Button>
</>
@@ -256,33 +274,43 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
<PageTab value="hits" label="Заблокированное" />
</PageTabsList>
<TabsContent value="policy" className="flex min-w-0 flex-col gap-5">
<AgentDiagnosticsPanel diagnostics={a.diagnostics} refreshing={agentQ.isFetching} onRefresh={() => void agentQ.refetch()} />
<div className="@container grid gap-5 @4xl:grid-cols-3">
<div className="@4xl:col-span-2">
<AgentPolicyTrace preview={previewQ.data} isLoading={previewQ.isLoading} />
<QueryState isLoading={previewQ.isPending} isError={previewQ.isError} error={previewQ.error} onRetry={() => void previewQ.refetch()}>
<AgentPolicyTrace preview={previewQ.data} />
</QueryState>
</div>
<AgentFactsPanel agent={a} />
</div>
<AgentPolicySetsSortable agentId={agentId} />
<AgentEffectiveCidrs preview={previewQ.data} isLoading={previewQ.isLoading} />
<QueryState isLoading={previewQ.isPending} isError={previewQ.isError} error={previewQ.error} onRetry={() => void previewQ.refetch()}>
<AgentEffectiveCidrs preview={previewQ.data} />
</QueryState>
</TabsContent>
{isLinux ? (
<TabsContent value="host" className="min-w-0">
<AgentHostFirewall agentId={agentId} />
<Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentHostFirewall agentId={agentId} /></Suspense>
</TabsContent>
) : null}
{isLinux ? (
<TabsContent value="acl" className="min-w-0">
<AgentPortAcl agentId={agentId} />
<Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentPortAcl agentId={agentId} /></Suspense>
</TabsContent>
) : null}
<TabsContent value="hits" className="flex min-w-0 flex-col gap-5">
{isLinux ? <AgentBlockedPorts agentId={agentId} /> : null}
<AgentBlockedIps agentId={agentId} platform={a.platform} />
{isLinux ? <Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentBlockedPorts agentId={agentId} /></Suspense> : null}
<Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentBlockedIps agentId={agentId} platform={a.platform} /></Suspense>
</TabsContent>
</Tabs>
<AgentOverrideSheet agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} />
<AgentCloneSetsSheet agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} />
<Suspense fallback={<p role="status" className="text-muted-foreground text-sm">Загрузка формы…</p>}>
{labelsMounted ? <AgentLabelsSheet key={`labels-${agentId}`} agentId={agentId} open={labelsOpen} onOpenChange={setLabelsOpen} /> : null}
{credentialsMounted ? <AgentCredentialsSheet key={`credentials-${agentId}`} agentId={agentId} open={credentialsOpen} onOpenChange={setCredentialsOpen} /> : null}
{mountedForms.override ? <AgentOverrideSheet key={`override-${agentId}`} agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} /> : null}
{mountedForms.explain ? <AgentExplainSheet key={`explain-${agentId}`} agentId={agentId} open={explainOpen} onOpenChange={setExplainOpen} /> : null}
{mountedForms.clone ? <AgentCloneSetsSheet key={`clone-${agentId}`} agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} /> : null}
</Suspense>
<ConfirmDialog
open={confirm !== null}
onOpenChange={(o) => !o && setConfirm(null)}
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { AgentDiagnostics } from '@evofw/shared'
import { AgentDiagnosticsPanel } from './agent-diagnostics-panel'
const diagnostics: AgentDiagnostics = {
desired_revision: 8, desired_hash: 'sha256:desired', applied_revision: 7, applied_hash: 'sha256:applied',
agent_version: '2.0', capabilities: ['nftables', 'traffic-v2'], last_contact_at: '2026-10-24T00:05:00Z',
last_telemetry_at: null, last_apply_at: null, apply_error: null, status: 'drift', telemetry_version: 2,
quality: { partial: true, truncated: false, unsupported: ['port'], lost_reports: 3 },
}
describe('agent diagnostic truthfulness', () => {
it('does not infer synchronized state from missing diagnostics', () => {
const html = renderToStaticMarkup(<AgentDiagnosticsPanel />)
expect(html).toContain('Нет диагностики')
expect(html).not.toContain('Синхронизирован')
})
it('keeps contact, telemetry, application and drift distinct', () => {
const html = renderToStaticMarkup(<AgentDiagnosticsPanel diagnostics={diagnostics} />)
expect(html).toContain('Расхождение политики')
expect(html).toContain('Не получена')
expect(html).toContain('Не подтверждено')
expect(html).toContain('sha256:desired')
expect(html).toContain('sha256:applied')
expect(html).toContain('Неполный')
expect(html).toContain('Потеряно отчётов: 3')
expect(html).toContain('traffic-v2')
})
it('shows structured failure without falsely declaring synchronization', () => {
const html = renderToStaticMarkup(<AgentDiagnosticsPanel diagnostics={{ ...diagnostics, status: 'failed', apply_error: { code: 'E_APPLY', stage: 'nft-check', message: 'Fixture validation failed', retryable: false } }} />)
expect(html).toContain('E_APPLY')
expect(html).toContain('nft-check')
expect(html).toContain('Fixture validation failed')
expect(html).not.toContain('Синхронизирован')
})
})
@@ -0,0 +1,69 @@
import type { AgentDiagnostics } from '@evofw/shared'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { Button } from '@evofw/ui/components/button'
import { formatStampDateTime } from '@/lib/format'
const STATUS = {
pending: { label: 'Ожидает применения', variant: 'secondary' },
applying: { label: 'Применяется', variant: 'info-light' },
in_sync: { label: 'Синхронизирован', variant: 'success-light' },
failed: { label: 'Ошибка применения', variant: 'destructive-light' },
drift: { label: 'Расхождение политики', variant: 'warning-light' },
unsupported: { label: 'Подтверждение недоступно', variant: 'secondary' },
} as const
export function AgentDiagnosticsPanel({ diagnostics, onRefresh, refreshing = false }: { diagnostics?: AgentDiagnostics; onRefresh?: () => void; refreshing?: boolean }) {
const status = diagnostics ? STATUS[diagnostics.status] : null
return (
<Frame dense spacing="sm">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-0.5">
<FrameTitle>Диагностика агента</FrameTitle>
<FrameDescription>Связь, телеметрия и подтверждение применения политики</FrameDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
{status ? <Badge variant={status.variant} size="sm">{status.label}</Badge> : <Badge variant="secondary" size="sm">Нет диагностики</Badge>}
{onRefresh ? <Button type="button" size="sm" variant="outline" disabled={refreshing} onClick={onRefresh}>Обновить</Button> : null}
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-4">
{!diagnostics ? <p className="text-muted-foreground text-sm">Агент ещё не передал диагностику. Наличие связи само по себе не подтверждает применение политики.</p> : <>
<dl className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[
['Последний контакт', diagnostics.last_contact_at ? formatStampDateTime(diagnostics.last_contact_at) : 'Не получен'],
['Телеметрия', diagnostics.last_telemetry_at ? formatStampDateTime(diagnostics.last_telemetry_at) : 'Не получена'],
['Применение', diagnostics.last_apply_at ? formatStampDateTime(diagnostics.last_apply_at) : 'Не подтверждено'],
['Ожидаемая ревизия', String(diagnostics.desired_revision)],
['Применённая ревизия', diagnostics.applied_revision === null ? 'Не подтверждена' : String(diagnostics.applied_revision)],
['Версия агента / телеметрии', `${diagnostics.agent_version ?? '—'} / ${diagnostics.telemetry_version ?? 'неизвестна'}`],
].map(([label, value]) => <div key={label} className="flex min-w-0 flex-col gap-1"><dt className="text-muted-foreground text-xs">{label}</dt><dd className="text-sm tabular-nums">{value}</dd></div>)}
</dl>
<dl className="grid gap-3 sm:grid-cols-2">
<div className="min-w-0"><dt className="text-muted-foreground text-xs">Ожидаемый hash</dt><dd className="truncate font-mono text-xs" title={diagnostics.desired_hash ?? undefined}>{diagnostics.desired_hash ?? 'Не рассчитан'}</dd></div>
<div className="min-w-0"><dt className="text-muted-foreground text-xs">Подтверждённый hash</dt><dd className="truncate font-mono text-xs" title={diagnostics.applied_hash ?? undefined}>{diagnostics.applied_hash ?? 'Не получен'}</dd></div>
</dl>
{diagnostics.apply_error ? <Alert variant="destructive">
<AlertTitle>{diagnostics.apply_error.code}{diagnostics.apply_error.stage ? ` · ${diagnostics.apply_error.stage}` : ''}</AlertTitle>
<AlertDescription>{diagnostics.apply_error.message}{diagnostics.apply_error.retryable === undefined ? '' : diagnostics.apply_error.retryable ? ' · Возможен повтор' : ' · Требуется вмешательство'}</AlertDescription>
</Alert> : null}
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs">Качество последнего отчёта:</span>
{diagnostics.telemetry_version !== 2 ? <Badge variant="secondary" size="sm">Legacy / v2 не подтверждён</Badge> : null}
{diagnostics.quality.partial ? <Badge variant="warning-light" size="sm">Неполный</Badge> : null}
{diagnostics.quality.truncated ? <Badge variant="warning-light" size="sm">Обрезан</Badge> : null}
{diagnostics.quality.lost_reports > 0 ? <Badge variant="warning-light" size="sm">Потеряно отчётов: {diagnostics.quality.lost_reports}</Badge> : null}
{diagnostics.quality.unsupported.map((metric) => <Badge key={metric} variant="secondary" size="sm">Нет поддержки: {metric}</Badge>)}
{diagnostics.telemetry_version === 2 && !diagnostics.quality.partial && !diagnostics.quality.truncated && diagnostics.quality.lost_reports === 0 && diagnostics.quality.unsupported.length === 0 ? <span className="text-muted-foreground text-xs">Без отмеченных ограничений</span> : null}
</div>
{diagnostics.detail_counts ? <p className="text-muted-foreground text-xs">Детализация: IP {diagnostics.detail_counts.ip_sent} из {diagnostics.detail_counts.ip_found} · порты {diagnostics.detail_counts.port_sent} из {diagnostics.detail_counts.port_found}</p> : null}
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs">Возможности:</span>
{diagnostics.capabilities.length ? diagnostics.capabilities.map((capability) => <Badge key={capability} variant="secondary" size="sm">{capability}</Badge>) : <span className="text-muted-foreground text-xs">Не переданы</span>}
</div>
</>}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,83 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { explainQuerySchema, explainResponseSchema, type ExplainQuery, type ExplainResponse } from '@evofw/shared'
import { FormSheet } from '@/components/form-sheet'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { formatStampDateTime } from '@/lib/format'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@evofw/ui/components/select'
import { Switch } from '@evofw/ui/components/switch'
import { EXPLAIN_DEFAULTS, explainDraftInput, explainDraftSchema, explainSearch, type ExplainDraft } from './explain-validation'
const STAGES = { stateful: 'Существующее соединение', deny: 'Блокирующий источник', port_close: 'Закрытый порт', port_open: 'Открытый порт', port_implicit_close: 'Источник вне разрешений порта', allow: 'Разрешающий источник', default: 'Действие по умолчанию', external: 'Внешние правила', unsupported: 'Кандидат не поддерживается', transport_unknown: 'Недостаточно данных о порте' } as const
const APPLIED_STATUS = { pending: 'Ожидает применения', applying: 'Применяется', in_sync: 'Синхронизирован', failed: 'Ошибка применения', drift: 'Расхождение политики', unsupported: 'Подтверждение недоступно' } as const
const CONNECTION_STATE = { new: 'новое соединение', established: 'установленное соединение', related: 'связанное соединение' } as const
const LIMITS: Record<string, string> = {
desired_policy_not_kernel_observation: 'Расчёт по желаемой политике; это не наблюдение пакета в ядре.',
external_firewall_may_override_accept: 'Разрешение EvoFirewall не гарантирует прохождение внешних правил и доступность сервиса.',
non_loopback_ingress_assumed: 'Предполагается входящий трафик не с loopback.',
port_acl_requires_nft_backend: 'Этот backend не применяет кандидаты с Port ACL.', mikrotik_port_acl_not_supported: 'Port ACL на MikroTik не поддерживается.',
ipv6_policy_capability_not_reported: 'Агент не подтвердил поддержку IPv6-политик.', ipv6_port_acl_capability_not_reported: 'Агент не подтвердил IPv6 Port ACL.',
established_related_precedes_owned_policy: 'Установленные и связанные соединения обрабатываются раньше управляемых правил.',
port_protocol_required_for_l4_decision: 'Для решения Port ACL нужны порт и протокол.',
mikrotik_input_allow_and_default_are_external: 'В этой цепочке MikroTik разрешение определяют внешние правила.',
mikrotik_accept_default_is_external_fallthrough: 'После управляемых правил решение передаётся внешним правилам MikroTik.',
ipset_forward_chain_not_managed: 'Этот backend не управляет транзитной цепочкой; решение определяют внешние правила.',
ipset_accept_default_is_external_fallthrough: 'После управляемых правил решение передаётся внешним правилам хоста.',
forward_port_acl_dnat_scope: 'Port ACL проверяется в области DNAT.', forward_port_acl_skipped_without_dnat: 'Обычный forward без DNAT не проверяет Port ACL.',
ipv6_runtime_capability_unknown: 'Поддержка IPv6 во время исполнения неизвестна.', source_attribution_is_logical_witness: 'Источник совпадения логический; это не счётчик конкретного правила ядра.',
}
export function AgentExplainResult({ result }: { result: ExplainResponse }) {
const decision = result.desired.decision
return <div className="flex flex-col gap-3">
<p className="text-muted-foreground text-xs">Результат для {result.query.ip}{result.query.port ? ` · ${result.query.protocol}/${result.query.port}` : ''} · {result.query.chain === 'input' ? 'входящий на хост' : 'транзитный'} · {CONNECTION_STATE[result.query.connection_state]}{result.query.dnat ? ' · DNAT' : ''}</p>
<Frame dense spacing="sm"><FrameHeader><FrameTitle>Желаемая политика</FrameTitle></FrameHeader><FramePanel className="flex flex-col gap-2">
<Badge variant={decision === 'drop' ? 'destructive-light' : decision === 'accept' ? 'success-light' : 'secondary'}>{decision === 'drop' ? 'Блокировать правилами EvoFirewall' : decision === 'accept' ? 'Разрешить правилами EvoFirewall' : 'Решение не определено'}</Badge>
<p className="text-sm">{STAGES[result.desired.stage]}</p><p className="text-muted-foreground text-xs">Ревизия {result.desired.revision}</p><p className="truncate font-mono text-xs" title={result.desired.hash}>{result.desired.hash}</p>
{result.desired.match ? <p className="text-sm">Логический источник: {result.desired.match.label}{result.desired.match.cidr ? ` · ${result.desired.match.cidr}` : ''}</p> : null}
</FramePanel></Frame>
<Frame dense spacing="sm"><FrameHeader><FrameTitle>Последнее подтверждение агента</FrameTitle></FrameHeader><FramePanel className="flex flex-col gap-2">
<Badge variant={result.applied.same_as_desired === true ? 'success-light' : result.applied.same_as_desired === false ? 'warning-light' : 'secondary'}>{result.applied.same_as_desired === true ? 'Совпадает с желаемой' : result.applied.same_as_desired === false ? 'Отличается от желаемой' : 'Совпадение неизвестно'}</Badge>
<p className="text-muted-foreground text-xs">Ревизия: {result.applied.revision ?? 'не подтверждена'} · {APPLIED_STATUS[result.applied.status]}</p><p className="truncate font-mono text-xs" title={result.applied.hash ?? undefined}>{result.applied.hash ?? 'Hash не подтверждён'}</p>
</FramePanel></Frame>
<Frame dense spacing="sm"><FrameHeader><FrameTitle>Границы объяснения</FrameTitle></FrameHeader><FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">Внешний firewall: {result.external_firewall.observed_at ? `снимок ${formatStampDateTime(result.external_firewall.observed_at)}, правил ${result.external_firewall.rule_count}` : 'снимок не получен'}. Снимок неполный; итоговое прохождение пакета не подтверждается.</p>
<ul className="flex flex-col gap-2 text-xs">{result.limits.map((limit) => <li key={limit}>{LIMITS[limit] ?? `Ограничение: ${limit}`}</li>)}</ul>
</FramePanel></Frame>
{result.matches.length ? <div className="flex flex-col gap-2"><p className="text-sm font-medium">Логические совпадения: {result.matches.length}/{result.matches_total}{result.truncated ? ' · список сокращён' : ''}</p><ul className="flex flex-col gap-2 text-xs">{result.matches.map((match, index) => <li key={`${match.rule_id ?? match.override_id ?? 'source'}-${index}`}>{match.label}{match.cidr ? ` · ${match.cidr}` : ''} · {STAGES[match.stage]}</li>)}</ul></div> : null}
</div>
}
export function AgentExplainSheet({ agentId, open, onOpenChange }: { agentId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const canRead = useCan()('fw:agents:read')
const form = useForm<ExplainDraft>({ resolver: zodResolver(explainDraftSchema), defaultValues: EXPLAIN_DEFAULTS })
const [request, setRequest] = useState<ExplainQuery | null>(null)
const search = request ? explainSearch(request) : ''
const result = useQuery({ queryKey: ['agents', agentId, 'explain', search], enabled: open && canRead && request !== null,
queryFn: async ({ signal }) => explainResponseSchema.parse(await apiFetch(`/api/v1/agents/${agentId}/explain?${search}`, { signal })), staleTime: 0 })
return <FormSheet open={open} onOpenChange={onOpenChange} title="Почему принято решение" description="Желаемая политика и последнее подтверждение агента. Explain не отправляет пакет и не меняет правила." form={form}
onSubmit={(values) => { const next = explainQuerySchema.parse(explainDraftInput(values)); form.reset(values); if (request && explainSearch(next) === search) void result.refetch(); else setRequest(next) }}
footer={<><Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Закрыть</Button><LoadingButton type="submit" isLoading={result.isFetching} disabled={!canRead}>Проверить</LoadingButton></>}>
<FieldGroup className="gap-3 py-2">
<Field data-invalid={Boolean(form.formState.errors.ip) || undefined}><FieldLabel htmlFor="explain-ip">Исходный IP</FieldLabel><Input id="explain-ip" {...form.register('ip')} placeholder="203.0.113.10" aria-invalid={Boolean(form.formState.errors.ip) || undefined} /><FieldError errors={[form.formState.errors.ip]} /></Field>
<Field data-invalid={Boolean(form.formState.errors.port) || undefined}><FieldLabel htmlFor="explain-port">Порт назначения (необязательно)</FieldLabel><Input id="explain-port" inputMode="numeric" {...form.register('port')} aria-invalid={Boolean(form.formState.errors.port) || undefined} /><FieldError errors={[form.formState.errors.port]} /></Field>
{([
['protocol', 'Протокол', [{ value: 'none', label: 'Без порта и протокола' }, { value: 'tcp', label: 'TCP' }, { value: 'udp', label: 'UDP' }]],
['chain', 'Цепочка', [{ value: 'input', label: 'Входящий на хост (input)' }, { value: 'forward', label: 'Транзитный (forward)' }]],
['connection_state', 'Состояние соединения', [{ value: 'new', label: 'Новое' }, { value: 'established', label: 'Установленное' }, { value: 'related', label: 'Связанное' }]],
] as const).map(([name, label, items]) => <Field key={name}><FieldLabel htmlFor={`explain-${name}`}>{label}</FieldLabel><Controller control={form.control} name={name} render={({ field }) => <Select value={field.value} onValueChange={field.onChange} items={[...items]}><SelectTrigger id={`explain-${name}`}><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /></Field>)}
<Field><FieldLabel htmlFor="explain-dnat">DNAT</FieldLabel><Controller control={form.control} name="dnat" render={({ field }) => <Switch id="explain-dnat" checked={field.value} onCheckedChange={field.onChange} />} /><FieldDescription>Port ACL для forward проверяется только в области DNAT.</FieldDescription></Field>
</FieldGroup>
{request ? <div className="flex flex-col gap-3 pb-4">{form.formState.isDirty ? <Badge variant="warning-light">Форма изменена — запустите проверку снова</Badge> : null}<QueryState hasData={result.data !== undefined} isLoading={result.isPending} isError={result.isError} error={result.error} onRetry={() => void result.refetch()}>{result.data ? <AgentExplainResult result={result.data} /> : null}</QueryState></div> : null}
</FormSheet>
}
@@ -18,6 +18,7 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import {
agentHostFirewallQueryOptions,
@@ -231,10 +232,11 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
{q.data?.collected_at
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
: ' Пока нет снимка — дождитесь sync агента.'}
: q.isSuccess ? ' Пока нет снимка — дождитесь sync агента.' : ''}
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<QueryState isLoading={q.isPending} isError={q.isError} error={q.error} onRetry={() => void q.refetch()}>
<CountedLineTabs
value={tab}
onValueChange={setTab}
@@ -324,6 +326,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
)}
</TabsContent>
</CountedLineTabs>
</QueryState>
</FramePanel>
</Frame>
)
@@ -0,0 +1,608 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import {
agentLabelBodySchema,
patchAgentLabelSchema,
putAgentLabelsSchema,
previewLabelAssignmentSchema,
applyLabelAssignmentSchema,
type AgentLabel,
type LabelAssignmentPreview,
type PreviewLabelAssignment,
} from '@evofw/shared'
import { FormSheet } from '@/components/form-sheet'
import { ResourcePage } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { NamedMultiSelect } from '@/components/named-multi-select'
import { StatsAgentSelect } from '@/components/stats/stats-agent-select'
import { Alert, AlertTitle, AlertDescription } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { agentsQueryOptions, policySetsQueryOptions } from '@/queries'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import {
Field,
FieldGroup,
FieldLabel,
FieldError,
} from '@evofw/ui/components/field'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
} from '@evofw/ui/components/select'
export const labelsQueryOptions = () => ({
queryKey: ['agent-labels'],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const first = await apiFetch<{ items: AgentLabel[]; total: number }>(
'/api/v1/agent-labels?limit=50',
{ signal },
)
const items = [...first.items]
for (let offset = 50; offset < Math.min(first.total, 10_000); offset += 50)
items.push(
...(
await apiFetch<{ items: AgentLabel[] }>(
`/api/v1/agent-labels?limit=50&offset=${offset}`,
{ signal },
)
).items,
)
return { ...first, items }
},
})
export function AgentLabelsSheet({
open,
onOpenChange,
agentId,
}: {
open: boolean
onOpenChange: (value: boolean) => void
agentId?: string
}) {
const can = useCan()
const qc = useQueryClient()
const read = can('fw:agents:read')
const write = can('fw:agents:write')
const form = useForm<z.infer<typeof agentLabelBodySchema>>({
resolver: zodResolver(agentLabelBodySchema),
defaultValues: { name: '', color: null },
})
const [editing, setEditing] = useState<AgentLabel>()
const [ids, setIds] = useState<string[]>([])
const [labelDirty, setLabelDirty] = useState(false)
const [selectorKind, setSelectorKind] = useState('labels')
const [selectedAgents, setSelectedAgents] = useState<string[]>([])
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const [sets, setSets] = useState<string[]>([])
const [mode, setMode] = useState<'add' | 'replace'>('add')
const [preview, setPreview] = useState<{
request: PreviewLabelAssignment
response: LabelAssignmentPreview
}>()
const [confirmation, setConfirmation] = useState<{
title: string
text: string
run: () => void
}>()
const labels = useQuery({ ...labelsQueryOptions(), enabled: open && read })
const assigned = useQuery({
queryKey: ['agents', agentId, 'labels'],
queryFn: ({ signal }) =>
apiFetch<{ agent_id: string; label_ids: string[] }>(
`/api/v1/agents/${agentId}/labels`,
{ signal },
),
enabled: open && read && Boolean(agentId),
})
useEffect(() => {
if (assigned.data && !labelDirty) setIds(assigned.data.label_ids)
}, [assigned.data, labelDirty])
const policySets = useQuery({
...policySetsQueryOptions(),
enabled: open && !agentId && can('fw:policies:read'),
})
const agents = useQuery({ ...agentsQueryOptions(), enabled: open && read })
const mutate = useMutation({
mutationFn: (task: () => Promise<unknown>) => task(),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['agent-labels'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => {
toast.error(e.message)
setPreview(undefined)
},
})
const send = (path: string, body: unknown, method = 'POST') =>
apiFetch(path, { method, body: JSON.stringify(body) })
const change = (task: () => void) => {
task()
setPreview(undefined)
}
const close = (value: boolean) => {
if (!value && (form.formState.isDirty || labelDirty || preview)) {
setConfirmation({
title: 'Закрыть черновик?',
text: 'Несохранённый черновик останется до ухода со страницы. Назначение не выполняется при закрытии.',
run: () => onOpenChange(false),
})
return
}
onOpenChange(value)
}
return (
<>
<FormSheet
open={open}
onOpenChange={close}
title={agentId ? 'Метки агента' : 'Метки и групповое назначение'}
description="Метки — метаданные. Изменение метки не назначает политики автоматически."
form={form}
size="xl"
onSubmit={(values) => {
if (!write) return
mutate.mutate(async () => {
await send(
editing
? `/api/v1/agent-labels/${editing.id}`
: '/api/v1/agent-labels',
editing
? patchAgentLabelSchema.parse(values)
: agentLabelBodySchema.parse(values),
editing ? 'PATCH' : 'POST',
)
form.reset({ name: '', color: null })
setEditing(undefined)
})
}}
footer={
<Button type="button" variant="outline" onClick={() => close(false)}>
Закрыть
</Button>
}
>
{!read ? (
<Alert>
<AlertTitle>Нет доступа к агентам</AlertTitle>
</Alert>
) : (
<QueryState
isLoading={labels.isLoading}
isError={labels.isError}
error={labels.error}
onRetry={() => void labels.refetch()}
>
{agentId ? (
<QueryState
isLoading={assigned.isLoading}
isError={assigned.isError}
error={assigned.error}
onRetry={() => void assigned.refetch()}
>
<NamedMultiSelect
id="agent-label-ids"
label="Метки агента"
items={labels.data?.items ?? []}
value={ids}
max={20}
disabled={!write}
onChange={(next) => {
setIds(next)
setLabelDirty(true)
}}
/>
{write && (
<Button
type="button"
disabled={
!labelDirty || mutate.isPending || assigned.isError
}
onClick={() =>
mutate.mutate(async () => {
await send(
`/api/v1/agents/${agentId}/labels`,
putAgentLabelsSchema.parse({ label_ids: ids }),
'PUT',
)
setLabelDirty(false)
})
}
>
Сохранить метки агента
</Button>
)}
</QueryState>
) : (
<>
<ResourcePage
title="Метки"
data={labels.data?.items ?? []}
getRowId={(r) => r.id}
columns={[
{ accessorKey: 'name', header: 'Название' },
{
accessorKey: 'color',
header: 'Цвет',
cell: ({ row }) => (
<Badge variant="outline">
{row.original.color ?? 'По умолчанию'}
</Badge>
),
},
{
id: 'actions',
header: 'Действия',
cell: ({ row }) =>
write ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={form.formState.isDirty}
onClick={() => {
setEditing(row.original)
form.reset({
name: row.original.name,
color: agentLabelBodySchema.shape.color.parse(
row.original.color,
),
})
}}
>
Изменить
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={mutate.isPending}
onClick={() =>
setConfirmation({
title: 'Удалить метку?',
text: `Метка «${row.original.name}» будет снята с агентов. Назначенные наборы правил сохранятся.`,
run: () =>
mutate.mutate(() =>
apiFetch(
`/api/v1/agent-labels/${row.original.id}`,
{ method: 'DELETE' },
),
),
})
}
>
Удалить
</Button>
</div>
) : null,
},
]}
filterFields={[]}
filters={[]}
onFiltersChange={() => {}}
getFilterFieldValue={() => undefined}
emptyState={{
title: 'Меток пока нет',
description:
'Создайте метку ниже, затем назначьте её агенту.',
}}
/>
{can('fw:policies:read') && (
<>
<Alert variant="info">
<AlertTitle>Материализованное назначение</AlertTitle>
<AlertDescription>
Предпросмотр фиксирует список агентов и поколения.
«Добавить» сохраняет прежний порядок наборов; «Заменить»
заменяет весь список. Изменения меток после операции не
меняют назначения.
</AlertDescription>
</Alert>
<Field>
<FieldLabel htmlFor="group-selector-mode">Выбор целей</FieldLabel>
<Select
value={selectorKind}
onValueChange={(value) =>
change(() => setSelectorKind(value ?? 'labels'))
}
items={[
{ value: 'labels', label: 'По меткам' },
{ value: 'agents', label: 'Явные агенты' },
]}
>
<SelectTrigger id="group-selector-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="labels">По меткам</SelectItem>
<SelectItem value="agents">Явные агенты</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{selectorKind === 'labels' ? (
<NamedMultiSelect
id="group-label-selector"
label="Все выбранные метки (AND)"
items={labels.data?.items ?? []}
value={selectedLabels}
max={20}
onChange={(ids) => change(() => setSelectedLabels(ids))}
/>
) : (
<StatsAgentSelect
id="group-agents"
label="Целевые агенты"
emptyMeansFleet={false}
value={selectedAgents}
onChange={(ids) => change(() => setSelectedAgents(ids))}
/>
)}
<QueryState
isLoading={policySets.isLoading}
isError={policySets.isError}
error={policySets.error}
onRetry={() => void policySets.refetch()}
>
<NamedMultiSelect
id="group-sets"
label="Наборы правил в выбранном порядке"
items={policySets.data?.items ?? []}
value={sets}
onChange={(ids) => change(() => setSets(ids))}
/>
</QueryState>
<Field>
<FieldLabel htmlFor="group-assignment-mode">Режим назначения</FieldLabel>
<Select
value={mode}
onValueChange={(value) =>
change(() =>
setMode(value === 'replace' ? 'replace' : 'add'),
)
}
items={[
{ value: 'add', label: 'Добавить к текущим' },
{ value: 'replace', label: 'Заменить весь список' },
]}
>
<SelectTrigger id="group-assignment-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="add">
Добавить к текущим
</SelectItem>
<SelectItem value="replace">
Заменить весь список
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Button
type="button"
variant="outline"
disabled={
mutate.isPending ||
!(selectorKind === 'labels'
? selectedLabels.length
: selectedAgents.length) ||
policySets.isError
}
onClick={() =>
mutate.mutate(async () => {
const request = previewLabelAssignmentSchema.parse({
selector:
selectorKind === 'labels'
? { label_ids: selectedLabels }
: { agent_ids: selectedAgents },
mode,
set_ids: sets,
})
const response = (await send(
'/api/v1/agent-labels/preview-assignment',
request,
)) as LabelAssignmentPreview
setPreview({ request, response })
})
}
>
Предпросмотр назначения
</Button>
{preview && (
<>
<ResourcePage
title={`Будут обработаны: ${preview.response.total}`}
data={preview.response.items}
getRowId={(r) => r.agent_id}
columns={[
{
id: 'agent',
header: 'Агент',
cell: ({ row }) =>
agents.data?.items.find(
(a) => a.id === row.original.agent_id,
)?.name ?? 'Недоступный агент',
},
{
id: 'sets',
header: 'Итоговый порядок',
cell: ({ row }) =>
row.original.set_ids
.map(
(id) =>
policySets.data?.items.find(
(s) => s.id === id,
)?.name ?? 'Недоступный набор',
)
.join(' → ') || 'Без наборов',
},
{
id: 'changed',
header: 'Изменится',
cell: ({ row }) =>
row.original.changed ? 'Да' : 'Нет',
},
]}
filterFields={[]}
filters={[]}
onFiltersChange={() => {}}
getFilterFieldValue={() => undefined}
emptyState={{ title: 'Нет подходящих агентов' }}
/>
{can('fw:policies:write') && (
<Button
type="button"
disabled={
mutate.isPending || !preview.response.items.length
}
onClick={() => {
const current = preview
setConfirmation({
title: 'Применить групповое назначение?',
text: `Режим: ${current.request.mode === 'replace' ? 'замена всего списка' : 'добавление'}. Агентов: ${current.response.total}. При изменении поколений или состава выборки операция будет отклонена; потребуется новый предпросмотр.`,
run: () =>
mutate.mutate(async () => {
await send(
'/api/v1/agent-labels/assign-policy-sets',
applyLabelAssignmentSchema.parse({
...current.request,
expected: current.response.items.map(
(item) => ({
agent_id: item.agent_id,
generation: item.generation,
}),
),
}),
)
setPreview(undefined)
}),
})
}}
>
Применить показанное назначение
</Button>
)}
</>
)}
</>
)}
</>
)}
{write && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="label-name">
{editing ? 'Изменить метку' : 'Новая метка'}
</FieldLabel>
<Input
id="label-name"
{...form.register('name')}
aria-invalid={Boolean(form.formState.errors.name)}
/>
<FieldError errors={[form.formState.errors.name]} />
</Field>
<Field>
<FieldLabel htmlFor="label-color">Цвет метаданных</FieldLabel>
<Select
value={form.watch('color') ?? 'none'}
onValueChange={(value) =>
form.setValue(
'color',
value === 'none'
? null
: agentLabelBodySchema.shape.color.parse(value),
{ shouldDirty: true },
)
}
items={[
'none',
'primary',
'success',
'warning',
'destructive',
'secondary',
'info',
].map((value) => ({
value,
label: value === 'none' ? 'По умолчанию' : value,
}))}
>
<SelectTrigger id="label-color">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[
'none',
'primary',
'success',
'warning',
'destructive',
'secondary',
'info',
].map((color) => (
<SelectItem key={color} value={color}>
{color === 'none' ? 'По умолчанию' : color}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Button
type="submit"
variant="outline"
disabled={mutate.isPending}
>
{editing ? 'Сохранить метку' : 'Создать метку'}
</Button>
{editing && (
<Button
type="button"
variant="ghost"
onClick={() => {
setEditing(undefined)
form.reset({ name: '', color: null })
}}
>
Отменить редактирование
</Button>
)}
</FieldGroup>
)}
</QueryState>
)}
</FormSheet>
<ConfirmDialog
open={Boolean(confirmation)}
onOpenChange={(value) => {
if (!value) setConfirmation(undefined)
}}
title={confirmation?.title ?? ''}
description={confirmation?.text ?? ''}
confirmLabel="Подтвердить"
onConfirm={() => {
confirmation?.run()
setConfirmation(undefined)
}}
/>
</>
)
}
@@ -1,3 +1,4 @@
import { invalidateAfterMutation } from '@/queries/invalidation'
import { useEffect, useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
@@ -24,6 +25,8 @@ import {
import { StatusBadge } from '@/components/status-badge'
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import { useCan } from '@/lib/permissions'
import {
agentPolicySetsQueryOptions,
policySetsQueryOptions,
@@ -60,34 +63,39 @@ export function AgentPolicySetsSortable({
agentId,
}: AgentPolicySetsSortableProps) {
const qc = useQueryClient()
const canWrite = useCan()('fw:agents:write')
const assignedQ = useQuery(agentPolicySetsQueryOptions(agentId))
const catalogQ = useQuery(policySetsQueryOptions())
const [items, setItems] = useState<AgentPolicySetRow[]>([])
const [addId, setAddId] = useState<string | null>(null)
useEffect(() => {
setItems(assignedQ.data?.items ?? [])
if (assignedQ.data) setItems(assignedQ.data.items)
}, [assignedQ.data])
const canEdit = canWrite && assignedQ.isSuccess && catalogQ.isSuccess
const availableSets = useMemo(() => {
const assigned = new Set(items.map((i) => i.set_id))
return (catalogQ.data?.items ?? []).filter((s) => !assigned.has(s.id))
}, [catalogQ.data?.items, items])
const persist = useMutation({
mutationFn: (set_ids: string[]) =>
apiFetch<{ items: AgentPolicySetRow[] }>(
onMutate: () => qc.cancelQueries({ queryKey: agentPolicySetsQueryOptions(agentId).queryKey }),
mutationFn: (set_ids: string[]) => {
if (!canEdit) throw new Error('Дождитесь загрузки назначенных наборов и каталога')
return apiFetch<{ items: AgentPolicySetRow[] }>(
`/api/v1/agents/${agentId}/policy-sets`,
{
method: 'PUT',
body: JSON.stringify({ set_ids }),
},
),
)
},
onSuccess: (res) => {
setItems(res.items)
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
qc.setQueryData(agentPolicySetsQueryOptions(agentId).queryKey, res)
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => {
toast.error(e.message)
@@ -95,6 +103,7 @@ export function AgentPolicySetsSortable({
})
const handleRemove = (setId: string) => {
if (!canEdit || persist.isPending) return
const next = items.filter((i) => i.set_id !== setId)
const prev = items
setItems(next)
@@ -108,7 +117,7 @@ export function AgentPolicySetsSortable({
}
const handleAdd = () => {
if (!addId) return
if (!addId || !canEdit || persist.isPending) return
const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId)
if (!set) return
const next: AgentPolicySetRow[] = [
@@ -150,6 +159,7 @@ export function AgentPolicySetsSortable({
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Select
disabled={!canEdit || persist.isPending}
value={addId}
onValueChange={(v) => setAddId(v)}
items={availableSets.map((s) => ({
@@ -175,7 +185,7 @@ export function AgentPolicySetsSortable({
</Select>
<Button
size="sm"
disabled={!addId || persist.isPending}
disabled={!addId || !canEdit || persist.isPending}
onClick={handleAdd}
>
<Plus data-icon="inline-start" />
@@ -184,6 +194,7 @@ export function AgentPolicySetsSortable({
</div>
</FrameHeader>
<QueryState isLoading={assignedQ.isPending || catalogQ.isPending} isError={assignedQ.isError || catalogQ.isError} error={assignedQ.error ?? catalogQ.error} onRetry={() => { void assignedQ.refetch(); void catalogQ.refetch() }}>
{items.length === 0 ? (
<FramePanel className="p-0">
<EmptyState
@@ -211,6 +222,7 @@ export function AgentPolicySetsSortable({
<SortableItem
key={row.set_id}
value={row.set_id}
disabled={!canEdit || persist.isPending}
className="border-border flex items-center gap-3 border-b px-3 py-2.5 last:border-b-0"
>
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
@@ -254,7 +266,7 @@ export function AgentPolicySetsSortable({
variant="ghost"
className="text-destructive"
aria-label="Снять набор"
disabled={persist.isPending}
disabled={!canEdit || persist.isPending}
onClick={() => handleRemove(row.set_id)}
>
<Trash2 className="size-3.5" />
@@ -264,6 +276,7 @@ export function AgentPolicySetsSortable({
</Sortable>
</FramePanel>
)}
</QueryState>
</Frame>
</div>
)
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormSheet } from '@/components/form-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldError, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@evofw/ui/components/select'
import { portAclDraftSchema, portAclImportDraftSchema, type PortAclDraft, type PortAclImportDraft } from './port-acl-validation'
export type AclCatalog = { items: { value: string; label: string }[]; loading: boolean; ready: boolean; error: Error | null; retry: () => void }
type BaseProps = { open: boolean; onOpenChange: (open: boolean) => void; pending: boolean; canWrite: boolean; lists: AclCatalog }
export function AgentPortRuleForm({ open, onOpenChange, pending, canWrite, lists, initialValues, title, description, onSubmit }: BaseProps & { initialValues: PortAclDraft; title: string; description: string; onSubmit: (values: PortAclDraft) => void }) {
const form = useForm<PortAclDraft>({ resolver: zodResolver(portAclDraftSchema, undefined, { raw: true }), defaultValues: initialValues, mode: 'onBlur' })
const [discardOpen, setDiscardOpen] = useState(false)
useEffect(() => { if (open) form.reset(initialValues) }, [open, initialValues, form])
const source = form.watch('src_kind')
const close = (next: boolean) => { if (pending) return; if (!next && form.formState.isDirty) setDiscardOpen(true); else onOpenChange(next) }
return <>
<FormSheet open={open} onOpenChange={close} title={title} description={description} form={form} onSubmit={onSubmit} footer={<><Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>Отмена</Button><LoadingButton type="submit" isLoading={pending} disabled={!canWrite || (source === 'list' && !lists.ready)}>Сохранить</LoadingButton></>}>
<fieldset disabled={!canWrite || pending} className="contents"><FieldGroup className="gap-3 py-2">
{source === 'list' ? <QueryState isLoading={lists.loading} isError={Boolean(lists.error)} error={lists.error} onRetry={lists.retry}>{null}</QueryState> : null}
{([
['action', 'Действие', [{ value: 'open', label: 'Открыть' }, { value: 'close', label: 'Закрыть' }]],
['protocol', 'Протокол', [{ value: 'tcp', label: 'TCP' }, { value: 'udp', label: 'UDP' }, { value: 'both', label: 'TCP и UDP' }]],
] as const).map(([name, label, items]) => <Field key={name}><FieldLabel htmlFor={`acl-${name}`}>{label}</FieldLabel><Controller control={form.control} name={name} render={({ field }) => <Select disabled={!canWrite || pending} value={field.value} onValueChange={field.onChange} items={[...items]}><SelectTrigger id={`acl-${name}`} onBlur={field.onBlur}><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /></Field>)}
<div className="grid grid-cols-2 gap-2">{(['port_start', 'port_end'] as const).map((name) => <Field key={name} data-invalid={Boolean(form.formState.errors[name]) || undefined}><FieldLabel htmlFor={`acl-${name}`}>{name === 'port_start' ? 'Первый порт' : 'Последний порт (необязательно)'}</FieldLabel><Input id={`acl-${name}`} inputMode="numeric" {...form.register(name)} aria-invalid={Boolean(form.formState.errors[name]) || undefined} /><FieldError errors={[form.formState.errors[name]]} /></Field>)}</div>
<Field><FieldLabel htmlFor="acl-source">Источник</FieldLabel><Controller control={form.control} name="src_kind" render={({ field }) => <Select value={field.value} onValueChange={field.onChange} disabled={!canWrite || pending} items={[{ value: 'all', label: 'Все адреса' }, { value: 'cidr', label: 'IP / CIDR' }, { value: 'list', label: 'IP-список' }]}><SelectTrigger id="acl-source" onBlur={field.onBlur}><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="all">Все адреса</SelectItem><SelectItem value="cidr">IP / CIDR</SelectItem><SelectItem value="list">IP-список</SelectItem></SelectGroup></SelectContent></Select>} /></Field>
{source === 'cidr' ? <Field data-invalid={Boolean(form.formState.errors.src_cidr) || undefined}><FieldLabel htmlFor="acl-src-cidr">IP / CIDR</FieldLabel><Input id="acl-src-cidr" {...form.register('src_cidr')} aria-invalid={Boolean(form.formState.errors.src_cidr) || undefined} /><FieldError errors={[form.formState.errors.src_cidr]} /></Field> : null}
{source === 'list' ? <Field data-invalid={Boolean(form.formState.errors.list_id) || undefined}><FieldLabel htmlFor="acl-list">IP-список</FieldLabel><Controller control={form.control} name="list_id" render={({ field }) => <Select value={field.value || null} onValueChange={(value) => field.onChange(value ?? '')} items={lists.items} disabled={!canWrite || pending || !lists.ready}><SelectTrigger id="acl-list" onBlur={field.onBlur} aria-invalid={Boolean(form.formState.errors.list_id) || undefined}><SelectValue placeholder="Выберите список" /></SelectTrigger><SelectContent><SelectGroup>{lists.items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /><FieldError errors={[form.formState.errors.list_id]} /></Field> : null}
<Field data-invalid={Boolean(form.formState.errors.comment) || undefined}><FieldLabel htmlFor="acl-comment">Комментарий</FieldLabel><Input id="acl-comment" {...form.register('comment')} aria-invalid={Boolean(form.formState.errors.comment) || undefined} /><FieldError errors={[form.formState.errors.comment]} /></Field>
</FieldGroup></fieldset>
</FormSheet>
<ConfirmDialog open={discardOpen} onOpenChange={setDiscardOpen} title="Закрыть без сохранения?" description="Несохранённые изменения правила будут сброшены." confirmLabel="Сбросить" onConfirm={() => { form.reset(initialValues); setDiscardOpen(false); onOpenChange(false) }} />
</>
}
const IMPORT_DEFAULTS: PortAclImportDraft = { from: 'list', listId: '', setId: '', action: 'open', protocol: 'tcp', ports: '22,80,443' }
export function AgentPortImportForm({ open, onOpenChange, pending, canWrite, lists, sets, onSubmit }: BaseProps & { sets: AclCatalog; onSubmit: (values: PortAclImportDraft) => void }) {
const form = useForm<PortAclImportDraft>({ resolver: zodResolver(portAclImportDraftSchema), defaultValues: IMPORT_DEFAULTS, mode: 'onBlur' })
const [discardOpen, setDiscardOpen] = useState(false)
useEffect(() => { if (!open && !pending) form.reset(IMPORT_DEFAULTS) }, [open, pending, form])
const source = form.watch('from')
const catalog = source === 'list' ? lists : sets
const sourceField = source === 'list' ? 'listId' : 'setId'
const close = (next: boolean) => { if (pending) return; if (!next && form.formState.isDirty) setDiscardOpen(true); else onOpenChange(next) }
return <>
<FormSheet open={open} onOpenChange={close} title="Импорт Port ACL" description="Порты и источники из выбранного списка или набора." form={form} onSubmit={onSubmit} footer={<><Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>Отмена</Button><LoadingButton type="submit" isLoading={pending} disabled={!canWrite || !catalog.ready}>Импортировать</LoadingButton></>}>
<fieldset disabled={!canWrite || pending} className="contents"><FieldGroup className="gap-3 py-2">
<QueryState isLoading={catalog.loading} isError={Boolean(catalog.error)} error={catalog.error} onRetry={catalog.retry}>{null}</QueryState>
{([
['from', 'Источник', [{ value: 'list', label: 'IP-список' }, { value: 'set', label: 'Набор правил' }]],
['action', 'Действие', [{ value: 'open', label: 'Открыть' }, { value: 'close', label: 'Закрыть' }]],
['protocol', 'Протокол', [{ value: 'tcp', label: 'TCP' }, { value: 'udp', label: 'UDP' }, { value: 'both', label: 'TCP и UDP' }]],
] as const).map(([name, label, items]) => <Field key={name}><FieldLabel htmlFor={`acl-import-${name}`}>{label}</FieldLabel><Controller control={form.control} name={name} render={({ field }) => <Select disabled={!canWrite || pending} value={field.value} onValueChange={field.onChange} items={[...items]}><SelectTrigger id={`acl-import-${name}`} onBlur={field.onBlur}><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /></Field>)}
<Field data-invalid={Boolean(form.formState.errors[sourceField]) || undefined}><FieldLabel htmlFor="acl-import-source">{source === 'list' ? 'IP-список' : 'Набор правил'}</FieldLabel><Controller control={form.control} name={sourceField} render={({ field }) => <Select value={field.value || null} onValueChange={(value) => field.onChange(value ?? '')} items={catalog.items} disabled={!canWrite || pending || !catalog.ready}><SelectTrigger id="acl-import-source" onBlur={field.onBlur} aria-invalid={Boolean(form.formState.errors[sourceField]) || undefined}><SelectValue placeholder="Выберите источник" /></SelectTrigger><SelectContent><SelectGroup>{catalog.items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /><FieldError errors={[form.formState.errors[sourceField]]} /></Field>
<Field data-invalid={Boolean(form.formState.errors.ports) || undefined}><FieldLabel htmlFor="acl-import-ports">Порты · до 50 портов или диапазонов</FieldLabel><Input id="acl-import-ports" {...form.register('ports')} placeholder="22,80,443 или 8000-8010" aria-invalid={Boolean(form.formState.errors.ports) || undefined} /><FieldError errors={[form.formState.errors.ports]} /></Field>
</FieldGroup></fieldset>
</FormSheet>
<ConfirmDialog open={discardOpen} onOpenChange={setDiscardOpen} title="Закрыть без импорта?" description="Настройки несохранённого импорта будут сброшены." confirmLabel="Сбросить" onConfirm={() => { setDiscardOpen(false); onOpenChange(false) }} />
</>
}
+47 -389
View File
@@ -1,3 +1,4 @@
import { invalidateAfterMutation } from '@/queries/invalidation'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -19,6 +20,10 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import { parsePortRanges, portAclDraftSchema, portAclImportDraftSchema, type PortAclDraft, type PortAclImportDraft } from './port-acl-validation'
import { AgentPortRuleForm, AgentPortImportForm } from './agent-port-acl-forms'
import { useCan } from '@/lib/permissions'
import {
agentHostFirewallQueryOptions,
agentPortRulesQueryOptions,
@@ -32,8 +37,6 @@ import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Switch } from '@evofw/ui/components/switch'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import {
Select,
SelectContent,
@@ -41,15 +44,6 @@ import {
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
/**
* Desired Port ACL for Linux agent + system overlay from host snapshot.
@@ -65,17 +59,7 @@ type AgentPortAclProps = {
agentId: string
}
type FormState = {
action: 'open' | 'close'
protocol: 'tcp' | 'udp' | 'both'
port_start: string
port_end: string
src_kind: 'all' | 'cidr' | 'list'
src_cidr: string
list_id: string
comment: string
enabled: boolean
}
type FormState = PortAclDraft
type PortAclOwner = 'evofw' | 'system'
@@ -132,7 +116,7 @@ function normalizeProto(value?: string): 'tcp' | 'udp' | null {
return null
}
function parsePortRanges(value?: string): Array<{ start: number; end: number }> {
function parseObservedPortRanges(value?: string): Array<{ start: number; end: number }> {
if (!value) return []
const t = value.replace(/[{}]/g, ' ').trim()
const out: Array<{ start: number; end: number }> = []
@@ -277,7 +261,7 @@ function collectSystemRows(
}
}
const proto = normalizeProto(r.protocol)
const ranges = parsePortRanges(r.dport)
const ranges = parseObservedPortRanges(r.dport)
if (!proto || !ranges.length) {
const parsed = parseUfwLikeRaw(r.raw)
for (const p of parsed) {
@@ -316,6 +300,7 @@ function collectSystemRows(
export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const qc = useQueryClient()
const canWrite = useCan()('fw:agents:write')
const q = useQuery(agentPortRulesQueryOptions(agentId))
const hostQ = useQuery(agentHostFirewallQueryOptions(agentId))
const listsQ = useQuery(listsQueryOptions())
@@ -323,16 +308,9 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
const [overriding, setOverriding] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm)
const [initialDraft, setInitialDraft] = useState<FormState>(emptyForm)
const [importOpen, setImportOpen] = useState(false)
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
const [impListId, setImpListId] = useState('')
const [impSetId, setImpSetId] = useState('')
const [impAction, setImpAction] = useState<'open' | 'close'>('open')
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
const [impPorts, setImpPorts] = useState('22,80,443')
const [ownerFilter, setOwnerFilter] = useState<'all' | PortAclOwner>('all')
const lists = listsQ.data?.items ?? []
const sets = setsQ.data?.items ?? []
const listSelectItems = useMemo(
@@ -344,28 +322,12 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
[sets],
)
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'host-firewall'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
}
const save = useMutation({
mutationFn: async () => {
const portStart = Number(form.port_start)
const portEnd = form.port_end ? Number(form.port_end) : portStart
const body = {
action: form.action,
protocol: form.protocol,
port_start: portStart,
port_end: portEnd,
src_kind: form.src_kind,
src_cidr: form.src_kind === 'cidr' ? form.src_cidr : undefined,
list_id: form.src_kind === 'list' ? form.list_id : undefined,
enabled: form.enabled,
comment: form.comment || undefined,
}
mutationFn: async (values: PortAclDraft) => {
if (!canWrite) throw new Error('Недостаточно прав для изменения Port ACL')
const parsed = portAclDraftSchema.parse(values)
const body = { ...parsed, port_end: parsed.port_end ?? parsed.port_start }
if (editing) {
return apiFetch(`/api/v1/agents/${agentId}/port-rules/${editing.id}`, {
method: 'PATCH',
@@ -388,8 +350,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
setFormOpen(false)
setEditing(null)
setOverriding(false)
setForm(emptyForm())
invalidate()
setInitialDraft(emptyForm())
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -402,7 +364,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
}),
onSuccess: () => {
toast.success('Состояние обновлено')
invalidate()
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -414,35 +376,24 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
}),
onSuccess: () => {
toast.success('Правило удалено')
invalidate()
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
const doImport = useMutation({
mutationFn: async () => {
const ports = impPorts
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
if (s.includes('-')) {
const [a, b] = s.split('-')
return {
port_start: Number(a),
port_end: Number(b),
}
}
return { port_start: Number(s) }
})
mutationFn: async (input: PortAclImportDraft) => {
if (!canWrite) throw new Error('Недостаточно прав для импорта Port ACL')
const values = portAclImportDraftSchema.parse(input)
const ports = parsePortRanges(values.ports)
return apiFetch(`/api/v1/agents/${agentId}/port-rules/import`, {
method: 'POST',
body: JSON.stringify({
from: impFrom,
list_id: impFrom === 'list' ? impListId : undefined,
set_id: impFrom === 'set' ? impSetId : undefined,
action: impAction,
protocol: impProtocol,
from: values.from,
list_id: values.from === 'list' ? values.listId : undefined,
set_id: values.from === 'set' ? values.setId : undefined,
action: values.action,
protocol: values.protocol,
ports,
}),
})
@@ -450,7 +401,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
onSuccess: () => {
toast.success('Импорт выполнен')
setImportOpen(false)
invalidate()
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -458,14 +409,14 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const openCreate = () => {
setEditing(null)
setOverriding(false)
setForm(emptyForm())
setInitialDraft(emptyForm())
setFormOpen(true)
}
const openEdit = (row: AgentPortRuleDto) => {
setEditing(row)
setOverriding(false)
setForm({
setInitialDraft({
action: row.action,
protocol: row.protocol,
port_start: String(row.port_start),
@@ -482,7 +433,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const openOverride = (row: PortAclRow) => {
setEditing(null)
setOverriding(true)
setForm({
setInitialDraft({
...emptyForm(),
action: 'open',
protocol: row.protocol,
@@ -604,6 +555,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
cell: ({ row }) =>
row.original.owner === 'evofw' ? (
<Switch
disabled={!canWrite || toggle.isPending || remove.isPending}
checked={row.original.enabled}
onCheckedChange={() => {
const src = evofwRules.find((r) => r.id === row.original.id)
@@ -631,6 +583,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
className="size-7"
onClick={() => openOverride(row.original)}
aria-label="Переопределить"
disabled={!canWrite || save.isPending}
>
<PencilIcon className="size-3.5" />
</Button>
@@ -646,6 +599,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
className="size-7"
onClick={() => src && openEdit(src)}
aria-label="Edit"
disabled={!canWrite || save.isPending}
>
<PencilIcon className="size-3.5" />
</Button>
@@ -656,6 +610,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
className="size-7"
onClick={() => remove.mutate(row.original.id)}
aria-label="Delete"
disabled={!canWrite || remove.isPending || toggle.isPending}
>
<Trash2Icon className="size-3.5" />
</Button>
@@ -664,7 +619,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
},
},
],
[toggle, remove, evofwRules],
[toggle, remove, evofwRules, canWrite, save.isPending],
)
const table = useReactTable({
@@ -699,11 +654,12 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
type="button"
size="sm"
variant="outline"
disabled={!canWrite || !q.isSuccess}
onClick={() => setImportOpen(true)}
>
Импорт
</Button>
<Button type="button" size="sm" onClick={openCreate}>
<Button type="button" size="sm" disabled={!canWrite || !q.isSuccess} onClick={openCreate}>
<PlusIcon className="size-4" />
Добавить
</Button>
@@ -711,6 +667,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<QueryState isLoading={isLoading} isError={q.isError || hostQ.isError} error={q.error ?? hostQ.error} onRetry={() => { void q.refetch(); void hostQ.refetch() }}>
<Select
items={[...OWNER_ITEMS]}
value={ownerFilter}
@@ -756,316 +713,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<DataGridTable />
</DataGrid>
)}
</QueryState>
</FramePanel>
</Frame>
<Sheet
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) {
setEditing(null)
setOverriding(false)
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>
{overriding
? 'Open + список: порт станет whitelist, остальные внешние src — drop.'
: 'Preview: https://reui.io/preview/base/sheet-8'}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-3 py-2 pb-4">
<Field>
<FieldLabel>Action</FieldLabel>
<Select
value={form.action}
onValueChange={(v) =>
v && setForm((f) => ({ ...f, action: v as 'open' | 'close' }))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">open</SelectItem>
<SelectItem value="close">close</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Protocol</FieldLabel>
<Select
value={form.protocol}
onValueChange={(v) =>
v &&
setForm((f) => ({
...f,
protocol: v as FormState['protocol'],
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="both">both</SelectItem>
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field>
<FieldLabel>Port start</FieldLabel>
<Input
value={form.port_start}
onChange={(e) =>
setForm((f) => ({ ...f, port_start: e.target.value }))
}
inputMode="numeric"
placeholder="443"
/>
</Field>
<Field>
<FieldLabel>Port end</FieldLabel>
<Input
value={form.port_end}
onChange={(e) =>
setForm((f) => ({ ...f, port_end: e.target.value }))
}
inputMode="numeric"
placeholder="optional"
/>
</Field>
</div>
<Field>
<FieldLabel>Source</FieldLabel>
<Select
value={form.src_kind}
onValueChange={(v) =>
v &&
setForm((f) => ({
...f,
src_kind: v as FormState['src_kind'],
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">all (0.0.0.0/0)</SelectItem>
<SelectItem value="cidr">CIDR / IP</SelectItem>
<SelectItem value="list">IP list</SelectItem>
</SelectContent>
</Select>
</Field>
{form.src_kind === 'cidr' ? (
<Field>
<FieldLabel>CIDR</FieldLabel>
<Input
value={form.src_cidr}
onChange={(e) =>
setForm((f) => ({ ...f, src_cidr: e.target.value }))
}
placeholder="10.0.0.0/8"
/>
</Field>
) : null}
{form.src_kind === 'list' ? (
<Field>
<FieldLabel>List</FieldLabel>
<Select
items={listSelectItems}
value={form.list_id || null}
onValueChange={(v) =>
v && setForm((f) => ({ ...f, list_id: v }))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите список" />
</SelectTrigger>
<SelectContent>
{listSelectItems.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : null}
<Field>
<FieldLabel>Comment</FieldLabel>
<Input
value={form.comment}
onChange={(e) =>
setForm((f) => ({ ...f, comment: e.target.value }))
}
/>
</Field>
</div>
</ScrollArea>
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
>
Отмена
</Button>
<Button
type="button"
disabled={
save.isPending ||
!form.port_start ||
(form.src_kind === 'list' && !form.list_id)
}
onClick={() => save.mutate()}
>
Сохранить
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<Sheet open={importOpen} onOpenChange={setImportOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Импорт Port ACL</SheetTitle>
<SheetDescription>
Источник CIDR/list из набора или IP-list + порты.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-3 py-2 pb-4">
<Field>
<FieldLabel>From</FieldLabel>
<Select
value={impFrom}
onValueChange={(v) => v && setImpFrom(v as 'list' | 'set')}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">IP list</SelectItem>
<SelectItem value="set">Policy set</SelectItem>
</SelectContent>
</Select>
</Field>
{impFrom === 'list' ? (
<Field>
<FieldLabel>List</FieldLabel>
<Select
items={listSelectItems}
value={impListId || null}
onValueChange={(v) => v && setImpListId(v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Список" />
</SelectTrigger>
<SelectContent>
{listSelectItems.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : (
<Field>
<FieldLabel>Set</FieldLabel>
<Select
items={setSelectItems}
value={impSetId || null}
onValueChange={(v) => v && setImpSetId(v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Набор" />
</SelectTrigger>
<SelectContent>
{setSelectItems.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<Field>
<FieldLabel>Action</FieldLabel>
<Select
value={impAction}
onValueChange={(v) =>
v && setImpAction(v as 'open' | 'close')
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">open</SelectItem>
<SelectItem value="close">close</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Protocol</FieldLabel>
<Select
value={impProtocol}
onValueChange={(v) =>
v && setImpProtocol(v as typeof impProtocol)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="both">both</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Ports</FieldLabel>
<Input
value={impPorts}
onChange={(e) => setImpPorts(e.target.value)}
placeholder="22,80,443 или 8000-8010"
/>
</Field>
</div>
</ScrollArea>
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={() => setImportOpen(false)}
>
Отмена
</Button>
<Button
type="button"
disabled={
doImport.isPending ||
(impFrom === 'list' ? !impListId : !impSetId)
}
onClick={() => doImport.mutate()}
>
Импортировать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<AgentPortRuleForm open={formOpen} onOpenChange={setFormOpen} title={sheetTitle}
description={overriding ? 'Разрешить порт только источникам списка; остальные внешние источники будут заблокированы после sync.' : 'Входящие порты и разрешённые источники. Изменения применятся после sync агента.'}
initialValues={initialDraft} canWrite={canWrite} pending={save.isPending} onSubmit={(values) => save.mutate(values)}
lists={{ items: listSelectItems, loading: listsQ.isPending, ready: listsQ.isSuccess, error: listsQ.error, retry: () => void listsQ.refetch() }} />
<AgentPortImportForm open={importOpen} onOpenChange={setImportOpen} canWrite={canWrite} pending={doImport.isPending} onSubmit={(values) => doImport.mutate(values)}
lists={{ items: listSelectItems, loading: listsQ.isPending, ready: listsQ.isSuccess, error: listsQ.error, retry: () => void listsQ.refetch() }}
sets={{ items: setSelectItems, loading: setsQ.isPending, ready: setsQ.isSuccess, error: setsQ.error, retry: () => void setsQ.refetch() }} />
</>
)
}
@@ -1,10 +1,14 @@
import { invalidateAfterMutation } from '@/queries/invalidation'
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { parseListEntry } from '@evofw/shared'
import { createOverrideDraftSchema, overrideBody, OVERRIDE_DEFAULTS, type OverrideDraft } from './override-validation'
import { QueryState } from '@/components/query-state'
import { useCan } from '@/lib/permissions'
import { formatStampDateTime } from '@/lib/format'
import { Textarea } from '@evofw/ui/components/textarea'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
agentOverridesQueryOptions,
@@ -14,13 +18,14 @@ import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
import { Field, FieldError, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectGroup,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
@@ -50,23 +55,7 @@ const OVERRIDE_ACTION_ITEMS = [
{ value: 'allow', label: 'allow — пропускать' },
] as const
const overrideSchema = z.object({
cidr: z
.string()
.trim()
.min(1, 'Укажите CIDR или IP')
.refine((v) => {
try {
const kind = parseListEntry(v).kind
return kind === 'cidr' || kind === 'ip'
} catch {
return false
}
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
action: z.enum(['deny', 'allow']),
})
type OverrideValues = z.infer<typeof overrideSchema>
const overrideSchema = createOverrideDraftSchema()
export function AgentOverrideSheet({
agentId,
@@ -74,27 +63,27 @@ export function AgentOverrideSheet({
onOpenChange,
}: OverrideSheetProps) {
const qc = useQueryClient()
const canWrite = useCan()('fw:agents:write')
const overridesQ = useQuery({
...agentOverridesQueryOptions(agentId),
enabled: open,
})
const form = useForm<OverrideValues>({
const form = useForm<OverrideDraft>({
resolver: zodResolver(overrideSchema),
defaultValues: { cidr: '', action: 'deny' },
defaultValues: OVERRIDE_DEFAULTS,
mode: 'onBlur',
})
const add = useMutation({
mutationFn: (values: OverrideValues) =>
mutationFn: (values: OverrideDraft) =>
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
method: 'POST',
body: JSON.stringify(values),
body: JSON.stringify(overrideBody(overrideSchema.parse(values))),
}),
onSuccess: () => {
toast.success('Override добавлен — подхватится на следующей итерации sync')
form.reset({ cidr: '', action: 'deny' })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
form.reset(OVERRIDE_DEFAULTS)
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -106,8 +95,7 @@ export function AgentOverrideSheet({
}),
onSuccess: () => {
toast.success('Override удалён')
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -115,22 +103,23 @@ export function AgentOverrideSheet({
const items = overridesQ.data?.items ?? []
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Sheet open={open} onOpenChange={(next) => { if (!add.isPending) onOpenChange(next) }}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>IP override</SheetTitle>
<SheetDescription>
Мгновенные allow/deny поверх политики. Обновятся на агенте на
следующей итерации sync (~1 мин).
Allow/deny применяются на следующем sync. Allow не обходит deny-наборы.
Окончание срока меняет желаемую политику; офлайн-агент получит изменение при следующей связи.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-4 py-2 pb-4">
<form
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
className="grid gap-3"
onSubmit={form.handleSubmit((values) => add.mutate(values))}
>
<fieldset disabled={!canWrite || add.isPending} className="contents">
<FieldGroup className="gap-3">
<Field
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
>
@@ -170,6 +159,20 @@ export function AgentOverrideSheet({
)}
/>
</Field>
<Field data-invalid={Boolean(form.formState.errors.reason) || undefined}>
<FieldLabel htmlFor="ov-reason">Причина (необязательно)</FieldLabel>
<Textarea id="ov-reason" {...form.register('reason')} aria-invalid={Boolean(form.formState.errors.reason) || undefined} />
<FieldError errors={[form.formState.errors.reason]} />
</Field>
<Field>
<FieldLabel htmlFor="ov-expiry-mode">Срок действия</FieldLabel>
<Controller control={form.control} name="expiryMode" render={({ field }) => <Select value={field.value} onValueChange={field.onChange} items={[{ value: 'permanent', label: 'Без срока' }, { value: 'ttl', label: 'На время' }, { value: 'until', label: 'До даты' }]}>
<SelectTrigger id="ov-expiry-mode"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="permanent">Без срока</SelectItem><SelectItem value="ttl">На время</SelectItem><SelectItem value="until">До даты</SelectItem></SelectGroup></SelectContent>
</Select>} />
</Field>
{form.watch('expiryMode') === 'ttl' ? <Field data-invalid={Boolean(form.formState.errors.ttlMinutes) || undefined}><FieldLabel htmlFor="ov-ttl">Минут (1–43200)</FieldLabel><Input id="ov-ttl" inputMode="numeric" {...form.register('ttlMinutes')} aria-invalid={Boolean(form.formState.errors.ttlMinutes) || undefined} /><FieldError errors={[form.formState.errors.ttlMinutes]} /></Field> : null}
{form.watch('expiryMode') === 'until' ? <Field data-invalid={Boolean(form.formState.errors.expiresLocal) || undefined}><FieldLabel htmlFor="ov-until">До даты · часовой пояс браузера</FieldLabel><Input id="ov-until" type="datetime-local" {...form.register('expiresLocal')} aria-invalid={Boolean(form.formState.errors.expiresLocal) || undefined} /><FieldError errors={[form.formState.errors.expiresLocal]} /></Field> : null}
{form.formState.isDirty ? <Badge variant="warning-light" size="sm">Есть несохранённый override</Badge> : null}
<LoadingButton
type="submit"
size="sm"
@@ -178,15 +181,18 @@ export function AgentOverrideSheet({
>
Добавить
</LoadingButton>
</FieldGroup>
</fieldset>
</form>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">
Активные{' '}
Overrides{' '}
<Badge variant="secondary" size="sm">
{items.length}
{items.filter((item) => !item.expired).length} активных
</Badge>
</p>
<QueryState hasData={overridesQ.data !== undefined} isLoading={overridesQ.isPending} isError={overridesQ.isError} error={overridesQ.error} onRetry={() => void overridesQ.refetch()}>
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">Пока нет overrides</p>
) : (
@@ -196,8 +202,11 @@ export function AgentOverrideSheet({
key={o.id}
className="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-sm">
{o.cidr}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-mono text-sm">{o.cidr}</span>
{o.reason ? <span className="text-muted-foreground text-xs">{o.reason}</span> : null}
<span className="text-muted-foreground text-xs">Автор: {o.created_by_name ?? 'неизвестен'} · {o.expires_at ? `до ${formatStampDateTime(o.expires_at)}` : 'без срока'}</span>
{o.expired ? <><Badge variant="secondary" size="sm">Истёк</Badge><span className="text-muted-foreground text-xs">Исключён из желаемой политики</span></> : null}
</span>
<Badge
variant={
@@ -214,7 +223,7 @@ export function AgentOverrideSheet({
variant="ghost"
className="text-destructive"
aria-label="Удалить"
disabled={remove.isPending}
disabled={!canWrite || remove.isPending}
onClick={() => remove.mutate(o.id)}
>
<Trash2 className="size-3.5" />
@@ -223,12 +232,13 @@ export function AgentOverrideSheet({
))}
</ul>
)}
</QueryState>
</div>
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button type="button" variant="outline" disabled={add.isPending} onClick={() => onOpenChange(false)}>
Закрыть
</Button>
</SheetFooter>
@@ -265,8 +275,7 @@ export function AgentCloneSetsSheet({
toast.success('Наборы скопированы')
setCloneFrom(null)
onOpenChange(false)
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void invalidateAfterMutation(qc, 'policy')
},
onError: (e: Error) => toast.error(e.message),
})
@@ -1,95 +1,24 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { recentStatsQueryOptions } from '@/queries'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evofw/ui/components/chart'
import { Skeleton } from '@evofw/ui/components/skeleton'
/**
* Fleet apply throughput — live time-series from /api/v1/stats/recent.
* DNA: https://reui.io/preview/base/solution-agents-1
*/
const chartConfig = {
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
} satisfies ChartConfig
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { QueryState } from '@/components/query-state'
import { TrafficTimeseriesChart } from '@/components/stats/traffic-timeseries-chart'
import { timeseriesQueryOptions } from '@/queries/timeseries'
import { useStatsWindow } from '@/hooks/use-stats-window'
/** Packet deltas from normalized v2 telemetry, grouped by UTC minute. */
export function AgentsFleetChart() {
const stats = useQuery(recentStatsQueryOptions())
const series = useMemo(() => {
const items = [...(stats.data?.items ?? [])].reverse().slice(-40)
return items.map((s) => ({
t: s.recorded_at.slice(11, 19),
dropped: s.packets_dropped,
accepted: s.packets_accepted,
}))
}, [stats.data?.items])
const request = useStatsWindow('1h', [])
const stats = useQuery(timeseriesQueryOptions(request))
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Throughput</FrameTitle>
<FrameDescription>
Dropped / accepted по последним apply-снимкам флота
</FrameDescription>
<FrameTitle>Пакеты за минуту</FrameTitle>
<FrameDescription>Последний час · телеметрия v2 · время показано в часовом поясе браузера</FrameDescription>
</FrameHeader>
<FramePanel>
{stats.isLoading ? (
<Skeleton className="aspect-[3/1] w-full rounded-lg" />
) : series.length === 0 ? (
<p className="text-muted-foreground text-sm">
Пока нет статистики apply — появится после sync агентов.
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-[3/1] w-full">
<AreaChart data={series} margin={{ left: 0, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="t"
tickLine={false}
axisLine={false}
fontSize={11}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
width={36}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="dropped"
stroke="var(--color-dropped)"
fill="var(--color-dropped)"
fillOpacity={0.2}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="accepted"
stroke="var(--color-accepted)"
fill="var(--color-accepted)"
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
)}
<QueryState hasData={stats.data !== undefined} isLoading={stats.isPending} isError={stats.isError} error={stats.error} onRetry={() => void stats.refetch()}>
<TrafficTimeseriesChart items={stats.data?.items ?? []} compact />
</QueryState>
</FramePanel>
</Frame>
)
@@ -0,0 +1,18 @@
import { z } from 'zod'
import { explainQuerySchema, type ExplainQuery } from '@evofw/shared'
export const EXPLAIN_DEFAULTS = { ip: '', port: '', protocol: 'none' as const, chain: 'input' as const, dnat: false, connection_state: 'new' as const }
export const explainDraftSchema = z.object({ ip: z.string(), port: z.string(), protocol: z.enum(['none', 'tcp', 'udp']), chain: z.enum(['input', 'forward']), dnat: z.boolean(), connection_state: z.enum(['new', 'established', 'related']) }).superRefine((draft, ctx) => {
if (draft.port && !/^\d+$/.test(draft.port)) ctx.addIssue({ code: 'custom', path: ['port'], message: 'Укажите целый порт от 1 до 65535' })
const parsed = explainQuerySchema.safeParse(explainDraftInput(draft))
if (!parsed.success) for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: issue.path.length ? issue.path : ['port'], message: issue.path.length ? issue.message : 'Порт и протокол указываются вместе' })
})
export type ExplainDraft = z.infer<typeof explainDraftSchema>
export function explainDraftInput(draft: ExplainDraft) {
return { ...draft, ip: draft.ip.trim(), port: draft.port === '' ? undefined : draft.port, protocol: draft.protocol === 'none' ? undefined : draft.protocol, dnat: draft.dnat ? 'true' : 'false' }
}
export function explainSearch(query: ExplainQuery) {
const params = new URLSearchParams({ ip: query.ip, chain: query.chain, dnat: String(query.dnat), connection_state: query.connection_state })
if (query.port !== undefined && query.protocol) { params.set('port', String(query.port)); params.set('protocol', query.protocol) }
return params.toString()
}
@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { ExplainResponse } from '@evofw/shared'
import { explainDraftSchema, EXPLAIN_DEFAULTS } from './explain-validation'
import { createOverrideDraftSchema, overrideBody, OVERRIDE_DEFAULTS } from './override-validation'
import { AgentExplainResult } from './agent-explain-sheet'
import { portAclImportDraftSchema } from './port-acl-validation'
describe('F1 validation and unknown semantics', () => {
it('requires strict IP and paired port/protocol', () => {
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '300.0.0.1' }).success).toBe(false)
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '203.0.113.1', port: '443' }).success).toBe(false)
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '203.0.113.1', port: '1e3', protocol: 'tcp' }).success).toBe(false)
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '2001:db8::1', port: '443', protocol: 'tcp', chain: 'forward', dnat: true }).success).toBe(true)
})
it('bounds temporary overrides and emits only the selected expiry mechanism', () => {
const now = Date.parse('2026-10-24T00:00:00Z')
const schema = createOverrideDraftSchema(() => now)
const draft = { ...OVERRIDE_DEFAULTS, cidr: '10.0.0.1', expiryMode: 'ttl' as const, ttlMinutes: '15', reason: ' Incident ' }
expect(schema.safeParse(draft).success).toBe(true)
expect(overrideBody(draft)).toMatchObject({ ttl_seconds: 900, reason: 'Incident' })
expect(overrideBody(draft)).not.toHaveProperty('expires_at')
for (const ttlMinutes of ['0', '43201', '1e2', '1.5']) expect(schema.safeParse({ ...draft, ttlMinutes }).success).toBe(false)
expect(schema.safeParse({ ...draft, expiryMode: 'until', expiresLocal: '2026-10-23T23:59:00Z' }).success).toBe(false)
expect(schema.safeParse({ ...draft, expiryMode: 'until', expiresLocal: '2026-12-24T00:00:00Z' }).success).toBe(false)
const absolute = overrideBody({ ...draft, expiryMode: 'until', expiresLocal: '2026-10-24T01:00:00Z' })
expect(absolute).toHaveProperty('expires_at', '2026-10-24T01:00:00.000Z')
expect(absolute).not.toHaveProperty('ttl_seconds')
expect(schema.safeParse({ ...draft, reason: 'x'.repeat(1025) }).success).toBe(false)
})
it('retains unknown/external desired decisions separately from applied confirmation', () => {
const fixture: ExplainResponse = { agent_id: 'fixture', query: { ip: '203.0.113.1', port: null, protocol: null, chain: 'forward', dnat: false, connection_state: 'new' }, desired: { revision: 8, hash: 'desired', decision: null, stage: 'external', match: null }, applied: { revision: null, hash: null, same_as_desired: null, status: 'unsupported' }, matches: [], matches_total: 0, truncated: false, limits: ['desired_policy_not_kernel_observation', 'external_firewall_may_override_accept'], external_firewall: { observed_at: null, rule_count: 0, complete: false } }
const html = renderToStaticMarkup(<AgentExplainResult result={fixture} />)
expect(html).toContain('Решение не определено')
expect(html).toContain('Совпадение неизвестно')
expect(html).toContain('Снимок неполный')
expect(html).not.toContain('Разрешить правилами EvoFirewall')
})
it('validates import source and strict ports through the shared form schema', () => {
const draft = { from: 'list', listId: '', setId: '', action: 'open', protocol: 'tcp', ports: '80-' }
const invalid = portAclImportDraftSchema.safeParse(draft)
expect(invalid.success).toBe(false)
if (!invalid.success) expect(invalid.error.issues.map((issue) => issue.path[0])).toEqual(['listId', 'ports'])
expect(portAclImportDraftSchema.safeParse({ ...draft, listId: 'fixture-list', ports: '22,8000-8010' }).success).toBe(true)
})
})
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { createOverrideBodySchema } from '@evofw/shared'
export const OVERRIDE_DEFAULTS = { cidr: '', action: 'deny' as const, reason: '', expiryMode: 'permanent' as const, ttlMinutes: '60', expiresLocal: '' }
const draftSchema = z.object({ cidr: z.string(), action: z.enum(['allow', 'deny']), reason: z.string(), expiryMode: z.enum(['permanent', 'ttl', 'until']), ttlMinutes: z.string(), expiresLocal: z.string() })
export type OverrideDraft = z.infer<typeof draftSchema>
export function overrideBody(draft: OverrideDraft) {
return {
cidr: draft.cidr.trim(), action: draft.action, reason: draft.reason.trim() || undefined,
...(draft.expiryMode === 'ttl' ? { ttl_seconds: Number(draft.ttlMinutes) * 60 } : {}),
...(draft.expiryMode === 'until' ? { expires_at: Number.isFinite(Date.parse(draft.expiresLocal)) ? new Date(draft.expiresLocal).toISOString() : draft.expiresLocal } : {}),
}
}
export function createOverrideDraftSchema(clock: () => number = () => new Date().getTime()) {
return draftSchema.superRefine((draft, ctx) => {
const parsed = createOverrideBodySchema.safeParse(overrideBody(draft))
if (!parsed.success) for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: [issue.path[0] === 'ttl_seconds' ? 'ttlMinutes' : issue.path[0] === 'expires_at' ? 'expiresLocal' : String(issue.path[0] ?? 'cidr')], message: issue.message })
if (draft.expiryMode === 'ttl' && !/^\d+$/.test(draft.ttlMinutes)) ctx.addIssue({ code: 'custom', path: ['ttlMinutes'], message: 'Укажите целое число минут от 1 до 43200' })
if (draft.expiryMode === 'until') {
const expires = Date.parse(draft.expiresLocal)
const now = clock()
if (!Number.isFinite(expires) || expires <= now || expires - now > 30 * 86_400_000) ctx.addIssue({ code: 'custom', path: ['expiresLocal'], message: 'Выберите будущую дату в пределах 30 дней' })
}
})
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { parsePortRanges, portAclDraftSchema } from './port-acl-validation'
const draft = { action: 'open', protocol: 'tcp', port_start: '443', port_end: '', src_kind: 'all', src_cidr: '', list_id: '', comment: '', enabled: true }
describe('Port ACL operator input', () => {
it('rejects ambiguous numeric inputs and reversed single-rule ranges', () => {
for (const port of ['0', '65536', '1e3', '1.5', 'NaN', '']) {
expect(portAclDraftSchema.safeParse({ ...draft, port_start: port }).success).toBe(false)
}
expect(portAclDraftSchema.safeParse({ ...draft, port_start: '443', port_end: '80' }).success).toBe(false)
})
it('requires valid source input and emits canonical CIDRs', () => {
expect(portAclDraftSchema.safeParse({ ...draft, src_kind: 'list' }).success).toBe(false)
expect(portAclDraftSchema.safeParse({ ...draft, src_kind: 'cidr', src_cidr: '300.0.0.1/24' }).success).toBe(false)
expect(portAclDraftSchema.parse({ ...draft, src_kind: 'cidr', src_cidr: '10.2.3.4/24' }).src_cidr).toBe('10.2.3.0/24')
})
it('accepts explicit import ranges and rejects malformed or excessive requests', () => {
expect(parsePortRanges('22, 80 8000-8010')).toEqual([{ port_start: 22 }, { port_start: 80 }, { port_start: 8000, port_end: 8010 }])
for (const value of ['', '80-', '-80', '80-70', '0', '65536', '1e3', '1-2-3', Array(51).fill('80').join(',')]) {
expect(() => parsePortRanges(value)).toThrow()
}
})
})
@@ -0,0 +1,58 @@
import { z } from 'zod'
import { createAgentPortRuleBodySchema } from '@evofw/shared'
const portInput = z.string().trim()
.regex(/^\d+$/, 'Укажите целый порт от 1 до 65535')
.transform(Number)
.pipe(z.number().int().min(1, 'Минимальный порт — 1').max(65535, 'Максимальный порт — 65535'))
export const portAclDraftSchema = z.object({
action: z.enum(['open', 'close']),
protocol: z.enum(['tcp', 'udp', 'both']),
port_start: portInput,
port_end: z.union([z.literal('').transform(() => undefined), portInput]),
src_kind: z.enum(['all', 'cidr', 'list']),
src_cidr: z.string(),
list_id: z.string(),
enabled: z.boolean(),
comment: z.string(),
}).transform((draft) => ({
...draft,
src_cidr: draft.src_kind === 'cidr' ? draft.src_cidr.trim() : undefined,
list_id: draft.src_kind === 'list' ? draft.list_id : undefined,
comment: draft.comment.trim() || undefined,
})).transform((draft, ctx) => {
const parsed = createAgentPortRuleBodySchema.safeParse(draft)
if (!parsed.success) {
for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message })
return z.NEVER
}
const { priority: _priority, ...body } = parsed.data
return body
})
/** Strict syntax prevents partial ranges, exponent notation and NaN from
* silently becoming a different firewall request. */
export function parsePortRanges(value: string): { port_start: number; port_end?: number }[] {
const tokens = value.trim().split(/[,\s]+/).filter(Boolean)
if (!tokens.length || tokens.length > 50) throw new Error('Укажите от 1 до 50 портов или диапазонов')
return tokens.map((token) => {
if (!/^\d+(?:-\d+)?$/.test(token)) throw new Error(`Некорректный порт или диапазон: ${token}`)
const [start, end] = token.split('-')
const port_start = Number(start)
const port_end = end === undefined ? undefined : Number(end)
if (!Number.isInteger(port_start) || port_start < 1 || port_start > 65535 ||
(port_end !== undefined && (!Number.isInteger(port_end) || port_end < port_start || port_end > 65535))) {
throw new Error(`Порты должны быть от 1 до 65535, начало не больше конца: ${token}`)
}
return port_end === undefined ? { port_start } : { port_start, port_end }
})
}
export type PortAclDraft = z.input<typeof portAclDraftSchema>
export const portAclImportDraftSchema = z.object({ from: z.enum(['list', 'set']), listId: z.string(), setId: z.string(), action: z.enum(['open', 'close']), protocol: z.enum(['tcp', 'udp', 'both']), ports: z.string() }).superRefine((draft, ctx) => {
if (draft.from === 'list' && !draft.listId) ctx.addIssue({ code: 'custom', path: ['listId'], message: 'Выберите список' })
if (draft.from === 'set' && !draft.setId) ctx.addIssue({ code: 'custom', path: ['setId'], message: 'Выберите набор' })
try { parsePortRanges(draft.ports) } catch (error) { ctx.addIssue({ code: 'custom', path: ['ports'], message: error instanceof Error ? error.message : 'Некорректные порты' }) }
})
export type PortAclImportDraft = z.infer<typeof portAclImportDraftSchema>
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { ResourcePage } from './reui-kit'
import type { PolicyRollout, PolicySnapshotDiff } from '@evofw/shared'
import { canConfirmRollout, rolloutReady } from './rules/policy-rollouts-sheet'
import { webhookEditorBody, webhookEditorSchema } from './webhooks-sheet'
import { installLinkState } from './agents/agent-credentials-sheet'
import {
parseListImportDraft,
validatedListImportValues,
} from '@/lib/list-import-draft'
const diff: PolicySnapshotDiff = {
snapshot_id: 'snapshot',
items: [
{
agent_id: 'target',
expected_generation: 7,
before_hash: 'old',
candidate_hash: 'new',
added: 1,
removed: 0,
examples: [],
truncated: false,
default_action_changed: false,
port_rules_changed: false,
conflict: null,
},
],
}
const rollout: PolicyRollout = {
id: 'rollout',
snapshot_id: 'snapshot',
state: 'canary',
version: 3,
created_at: '',
updated_at: '',
targets: [
{
agent_id: 'canary',
is_canary: true,
phase: 'candidate',
desired_revision: 8,
desired_hash: 'new',
applied_revision: 8,
applied_hash: 'new',
acknowledged: true,
previous_desired_snapshot_id: 'previous',
},
],
}
describe('F2/F3 action gates and write-only drafts', () => {
it('uses server totals and current-page rows without a second local filter or pagination', () => {
const html = renderToStaticMarkup(<ResourcePage title="Server rows" data={[{ id: 'last', name: 'Visible server result' }]} getRowId={(r) => r.id} columns={[{ accessorKey: 'name', header: 'Name' }]} filterFields={[]} filters={[]} onFiltersChange={() => {}} getFilterFieldValue={() => undefined} searchQuery="server-filter-applied-before-page" getSearchText={(r) => r.name} onSearchChange={() => {}} serverPagination={{ value: { pageIndex: 999, pageSize: 100 }, total: 100000, onChange: () => {} }} />)
expect(html).toContain('Visible server result')
expect(html).toContain('99901–100000 из 100000')
})
it('requires the current snapshot and complete exact target diff without admission conflicts', () => {
expect(rolloutReady(diff, ['target'], 'snapshot')).toBe(true)
expect(rolloutReady(diff, ['target'], 'another-snapshot')).toBe(false)
expect(rolloutReady(diff, ['target', 'other'], 'snapshot')).toBe(false)
expect(rolloutReady(diff, ['other'], 'snapshot')).toBe(false)
expect(
rolloutReady(
{ ...diff, items: [{ ...diff.items[0], conflict: 'POLICY_PINNED' }] },
['target'],
'snapshot',
),
).toBe(false)
})
it('keeps published, pending, rolled-back and released canary states separate from confirmation', () => {
expect(canConfirmRollout(rollout)).toBe(true)
for (const phase of ['pending', 'released', 'rollback'] as const)
expect(
canConfirmRollout({
...rollout,
targets: [{ ...rollout.targets[0], phase }],
}),
).toBe(false)
expect(
canConfirmRollout({
...rollout,
targets: [{ ...rollout.targets[0], acknowledged: false }],
}),
).toBe(false)
expect(canConfirmRollout({ ...rollout, targets: [] })).toBe(false)
expect(canConfirmRollout({ ...rollout, state: 'confirmed' })).toBe(false)
})
it('omits an unchanged webhook secret and requires a valid new secret on creation', () => {
const draft = {
name: 'fixture',
url: 'https://fixture.invalid/hook',
events: ['agent.offline' as const],
secret: '',
enabled: false,
}
expect(webhookEditorBody(draft, true)).not.toHaveProperty('secret')
expect(() => webhookEditorBody(draft, false)).toThrow()
expect(
webhookEditorSchema.safeParse({ ...draft, secret: 'short' }).success,
).toBe(false)
expect(
webhookEditorBody(
{ ...draft, secret: 'fixture-only-secret-never-sent-00000000' },
false,
),
).toHaveProperty('enabled', false)
})
it('reports consumed, expired, revoked and active one-time links without exposing commands', () => {
const now = Date.parse('2026-10-24T00:00:00Z')
const link = {
revoked_at: null,
consumed_at: null,
expires_at: '2026-10-25T00:00:00Z',
}
expect(installLinkState(link, now)).toBe('Ожидает установки')
expect(installLinkState({ ...link, expires_at: null }, now)).toBe('Истекла')
expect(installLinkState(link, now + 86400000)).toBe('Истекла')
expect(
installLinkState({ ...link, consumed_at: '2026-10-23T00:00:00Z' }, now),
).toBe('Использована')
expect(
installLinkState(
{
...link,
consumed_at: '2026-10-23T00:00:00Z',
revoked_at: '2026-10-23T01:00:00Z',
},
now,
),
).toBe('Отозвана')
})
it('rejects mixed invalid imports before any request while normalizing valid duplicates', () => {
const mixed = '203.0.113.1\n999.999.999.999'
expect(parseListImportDraft(mixed)).toMatchObject({ valid: 1, invalid: 1 })
expect(() => validatedListImportValues(mixed)).toThrow(
'Частичный импорт не выполняется',
)
expect(validatedListImportValues('203.0.113.1\n203.0.113.1')).toEqual([
'203.0.113.1',
])
})
})
+5 -3
View File
@@ -22,6 +22,7 @@ interface FormSheetProps<T extends FieldValues> {
footer?: ReactNode
className?: string
contentClassName?: string
size?: 'default' | 'lg' | 'xl'
}
/**
@@ -40,13 +41,14 @@ export function FormSheet<T extends FieldValues>({
footer,
className,
contentClassName,
size = 'default',
}: FormSheetProps<T>) {
const handleSubmit = form.handleSubmit(onSubmit)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className={cn('flex flex-col gap-0 overflow-hidden sm:max-w-md', className)}
className={cn('flex min-w-0 flex-col gap-0 overflow-hidden sm:max-w-md', size === 'lg' && 'data-[side=right]:w-full data-[side=right]:sm:max-w-2xl', size === 'xl' && 'data-[side=right]:w-full data-[side=right]:sm:max-w-3xl', className)}
>
<SheetHeader className="shrink-0">
<SheetTitle>{title}</SheetTitle>
@@ -55,11 +57,11 @@ export function FormSheet<T extends FieldValues>({
<FormProvider {...form}>
<form
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
className="flex min-h-0 min-w-0 flex-1 flex-col"
>
<div
className={cn(
'grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4',
'grid min-w-0 flex-1 auto-rows-min grid-cols-[minmax(0,1fr)] gap-4 overflow-x-hidden overflow-y-auto px-4',
contentClassName,
)}
>
+41 -31
View File
@@ -5,11 +5,14 @@ import { AppSwitcher } from '@/components/app-switcher'
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
import { useUiStore } from '@/components/portal/chrome-store'
import { getClaims, isAuthEnabled, logout } from '@/lib/auth'
import { NAV_ITEMS, NAV_SECTIONS } from '@/lib/nav'
import { useCan } from '@/lib/permissions'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
import { agentsQueryOptions, dashboardQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries'
import { agentsQueryOptions, uiSummaryQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries'
import { isAgentOnline } from '@/lib/agent-online'
import { formatRelativeTime } from '@/lib/format'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
import { TooltipProvider } from '@evofw/ui/components/tooltip'
@@ -39,24 +42,24 @@ function initialsOf(name: string) {
/** Меню и данные оболочки EvoFirewall: пункты по правам, точки проблем и монитор из живых запросов. */
function useChromeData(): ChromeConfig {
const can = useCan()
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
const setsQ = useQuery({ ...policySetsQueryOptions(), refetchInterval: 60_000 })
const { paletteOpen } = useUiStore()
const summaryQ = useQuery(uiSummaryQueryOptions())
// Full catalogs are needed only for object search, never to render the shell.
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: paletteOpen && can('fw:agents:read'), refetchInterval: false, staleTime: 60_000 })
const listsQ = useQuery({ ...listsQueryOptions(), enabled: paletteOpen && can('fw:lists:read'), staleTime: 60_000 })
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: paletteOpen && can('fw:policies:read'), staleTime: 60_000 })
return useMemo<ChromeConfig>(() => {
const d = dashQ.data
const agents = agentsQ.data?.items ?? []
const lists = listsQ.data?.items ?? []
const sets = setsQ.data?.items ?? []
const approved = agents.filter((a) => a.status === 'approved').length
const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
const pending = d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
const offline = Math.max(0, approved - online)
const summary = summaryQ.data
const agents = can('fw:agents:read') ? agentsQ.data?.items ?? [] : []
const lists = can('fw:lists:read') ? listsQ.data?.items ?? [] : []
const sets = can('fw:policies:read') ? setsQ.data?.items ?? [] : []
const approved = summary?.agents?.approved ?? 0
const online = summary?.agents?.online ?? 0
const pending = summary?.agents?.pending ?? 0
const offline = summary?.agents?.offline ?? 0
const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
const apiOk = !dashQ.isError
const failedLists = lists.filter((l) => l.last_error).length
const rulesCount = sets.reduce((n, s) => n + (s.rules_count ?? 0), 0)
const failedLists = summary?.lists?.failed ?? 0
const problems: Record<string, NavItem['problem']> = {
'/agents': pending > 0 ? { tone: 'wn', label: `${pending} агентов ждут одобрения` } : offline > 0 ? { tone: 'er', label: `${offline} агентов офлайн` } : undefined,
@@ -75,18 +78,23 @@ function useChromeData(): ChromeConfig {
const groups = [...new Set(navItems.map((i) => i.group))]
const monitorItems: MonitorItem[] = []
if (!apiOk) monitorItems.push({ id: 'api', tone: 'er', title: 'API недоступен', text: 'GET /api/v1/dashboard вернул ошибку', age: 'сейчас', to: '/' })
if (summaryQ.isPending) monitorItems.push({ id: 'loading', tone: 'wn', title: 'Проверка состояния', text: 'Ожидаем сводку сервера', age: 'загрузка', to: '/' })
if (summaryQ.isError) monitorItems.push({ id: 'api', tone: 'er', title: 'Не удалось обновить сводку', text: summaryQ.data ? 'Показаны последние полученные данные' : 'Состояние системы пока неизвестно', age: 'ошибка', to: '/' })
if (pending > 0) monitorItems.push({ id: 'pending', tone: 'wn', title: `Ждут одобрения: ${pending}`, text: 'Новые агенты не получают политику, пока их не одобрят', age: 'сейчас', to: '/agents' })
if (offline > 0) monitorItems.push({ id: 'offline', tone: 'er', title: `Агентов офлайн: ${offline}`, text: `Онлайн ${online} из ${approved} одобренных`, age: 'сейчас', to: '/agents' })
if (failedLists > 0) monitorItems.push({ id: 'lists', tone: 'wn', title: `Списков с ошибкой: ${failedLists}`, text: 'Проверьте источник списка и повторите обновление', age: 'недавно', to: '/lists' })
if (monitorItems.length === 0) monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, агенты и списки без проблем', age: 'сейчас', to: '/' })
if (monitorItems.length === 0) monitorItems.push(
summary && (summary.agents || summary.lists || summary.policies || summary.dashboard)
? { id: 'ok', tone: 'ok', title: 'В норме', text: 'В доступных разделах сводки проблем не обнаружено', age: 'проверено', to: '/' }
: { id: 'unknown', tone: 'wn', title: 'Нет доступной сводки', text: 'Нет данных для оценки состояния', age: 'неизвестно', to: '/' },
)
const metrics: MonitorMetric[] = [
{ l: 'API', v: apiOk ? 'OK' : 'ERR', p: apiOk ? 100 : 0, tone: apiOk ? 'success' : 'warning' },
{ l: 'Агенты online', v: `${onlinePct}%`, p: onlinePct, tone: onlinePct >= 80 ? 'success' : 'warning' },
{ l: 'Списки IP', v: String(d?.lists_total ?? lists.length), p: lists.length ? Math.round(((lists.length - failedLists) / lists.length) * 100) : 100, tone: failedLists > 0 ? 'warning' : 'success' },
{ l: 'Правила', v: String(rulesCount), p: Math.min(100, rulesCount), tone: 'success' },
{ l: 'API сводки', v: summaryQ.isSuccess ? 'OK' : summaryQ.isError ? 'ERR' : 'Проверка…', p: summaryQ.isSuccess ? 100 : 0, tone: summaryQ.isSuccess ? 'success' : 'warning' },
]
if (summary?.agents) metrics.push({ l: 'Агенты online', v: approved ? `${onlinePct}%` : 'Нет одобренных', p: onlinePct, tone: offline ? 'warning' : 'success' })
if (summary?.lists) metrics.push({ l: 'Списки IP', v: String(summary.lists.total), p: summary.lists.total ? Math.round(((summary.lists.total - failedLists) / summary.lists.total) * 100) : 100, tone: failedLists ? 'warning' : 'success' })
if (summary?.policies) metrics.push({ l: 'Правила', v: String(summary.policies.rules_total), p: 100, tone: 'success' })
const claims = getClaims()
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
@@ -98,7 +106,7 @@ function useChromeData(): ChromeConfig {
group: 'Агенты',
label: a.name,
hint: `${a.platform}${a.last_seen_ip ? ` · ${a.last_seen_ip}` : ''}`,
dot: a.status !== 'approved' ? 'mu' : a.last_seen_at ? 'ok' : 'er',
dot: a.status !== 'approved' ? 'mu' : isAgentOnline(a) ? 'ok' : 'er',
to: `/agents/${a.id}`,
})),
...lists.slice(0, 50).map<PaletteObject>((l) => ({ id: `list-${l.id}`, group: 'Списки', label: l.name, hint: l.type, dot: l.last_error ? 'er' : 'ok', to: `/lists/${l.id}` })),
@@ -113,23 +121,27 @@ function useChromeData(): ChromeConfig {
securityTo: '/settings',
onLogout: logout,
fresh: freshFor,
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/agents', footerLabel: 'Агенты' },
monitor: { items: monitorItems, metrics, updated: summary?.updated_at ? `Получено ${formatRelativeTime(summary.updated_at)}` : 'Данные ещё не получены', footerTo: can('fw:agents:read') ? '/agents' : '/', footerLabel: can('fw:agents:read') ? 'Агенты' : 'Обзор' },
palette: {
actions: [
{ id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents' },
{ id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists' },
{ id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules' },
...(can('fw:agents:write') ? [{ id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents?add=true' }] : []),
...(can('fw:lists:write') ? [{ id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists?create=true' }] : []),
...(can('fw:policies:write') ? [{ id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules?create=true' }] : []),
],
objects,
assistantTo: '/agents',
example: 'Подсказка: «агенты офлайн»',
example: 'Поиск по названию объекта или раздела',
},
}
}, [can, dashQ.data, dashQ.isError, agentsQ.data, listsQ.data, setsQ.data])
}, [can, summaryQ.data, summaryQ.isPending, summaryQ.isError, summaryQ.isSuccess, agentsQ.data, listsQ.data, setsQ.data])
}
/** Оболочка EvoFirewall: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
export function PortalShell({ children }: { children: ReactNode }) {
return <PortalUiProvider><PortalShellContent>{children}</PortalShellContent></PortalUiProvider>
}
function PortalShellContent({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(true)
const [mounted, setMounted] = useState(false)
useEffect(() => {
@@ -153,7 +165,6 @@ export function PortalShell({ children }: { children: ReactNode }) {
}
return (
<PortalUiProvider>
<ChromeConfigProvider value={config}>
<TooltipProvider delay={0}>
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
@@ -169,6 +180,5 @@ export function PortalShell({ children }: { children: ReactNode }) {
</SidebarProvider>
</TooltipProvider>
</ChromeConfigProvider>
</PortalUiProvider>
)
}
@@ -66,7 +66,7 @@ export function createListColumns(opts: {
title={row.original.name}
subtitle={
row.original.last_error
? 'Ошибка обновления'
? `Ошибка обновления · сохранено записей: ${row.original.entry_count ?? '—'}`
: row.original.refreshed_at
? `Обновлён ${formatDateTime(row.original.refreshed_at)}`
: undefined
@@ -96,18 +96,19 @@ export function createListColumns(opts: {
meta: { headerTitle: 'Записей' },
},
{
accessorKey: 'updated_at',
id: 'last_success',
accessorFn: (row) => isManualListType(row.type) ? row.updated_at : row.refreshed_at ?? '',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Обновлено" />
<DataGridColumnHeader column={column} title="Успешное обновление" />
),
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.updated_at
? formatDateTime(row.original.updated_at)
: '—'}
{(isManualListType(row.original.type) ? row.original.updated_at : row.original.refreshed_at)
? formatDateTime((isManualListType(row.original.type) ? row.original.updated_at : row.original.refreshed_at)!)
: 'Не было'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Обновлено' },
meta: { headerTitle: 'Успешное обновление' },
},
{
id: 'actions',
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more