feat(api, web): implement port ACL and host firewall snapshot features
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m43s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports.
- Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management.
- Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring.
- Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience.
- Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers.

These changes significantly improve the management and visibility of firewall rules and port access control for agents.
This commit is contained in:
Denozordec
2026-08-11 15:08:00 +07:00
parent c5069fbdaf
commit 43f5ac2525
22 changed files with 2853 additions and 17 deletions
+291 -4
View File
@@ -57,6 +57,7 @@ fi
parse_policy() {
local f="$1"
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f")
@@ -67,10 +68,11 @@ parse_policy() {
fi
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
jq -c '.port_rules // []' "$f" >"$PORT_RULES_FILE" 2>/dev/null || echo '[]' >"$PORT_RULES_FILE"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
eval "$(python3 - "$f" <<'PY'
eval "$(python3 - "$f" "$PORT_RULES_FILE" <<'PY'
import json,sys
d=json.load(open(sys.argv[1],encoding="utf-8"))
print(f'HASH={d.get("hash") or ""}')
@@ -80,6 +82,7 @@ if not da:
print(f'DEFAULT_ACTION={da}')
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")")
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
open(sys.argv[2],"w",encoding="utf-8").write(json.dumps(d.get("port_rules") or []))
PY
)"
return 0
@@ -89,11 +92,17 @@ PY
}
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
parse_policy "$POLICY_FILE"
# Empty deny/allow is valid — agent may have no rule sets yet.
DENY=("${DENY[@]+"${DENY[@]}"}")
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}")
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
[[ -f "$PORT_RULES_FILE" ]] || echo '[]' >"$PORT_RULES_FILE"
PORT_RULES_COUNT=0
if command -v python3 >/dev/null 2>&1; then
PORT_RULES_COUNT=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$PORT_RULES_FILE" 2>/dev/null || echo 0)
fi
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} port_rules=$PORT_RULES_COUNT hash=$HASH"
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
@@ -101,6 +110,7 @@ KERNEL_METHOD=""
APPLIED=0
IP_HITS_JSON="[]"
PORT_HITS_JSON="[]"
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
# 1 when deny_port_hits dynamic set is available for this apply.
PORT_HITS_ENABLED=0
@@ -371,6 +381,8 @@ apply_nft() {
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
fi
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
# Port ACL: close (drop) then open (accept), before default.
apply_nft_port_acl "$table" "$name"
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add rule "$table" "$name" input counter drop
else
@@ -380,6 +392,253 @@ apply_nft() {
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
}
# Apply desired L4 port open/close rules from PORT_RULES_FILE (apply_version 3).
apply_nft_port_acl() {
local table=$1 name=$2
[[ -f "$PORT_RULES_FILE" ]] || return 0
if ! command -v python3 >/dev/null 2>&1; then
log "nft port ACL skipped — need python3"
return 0
fi
# Delete prior per-rule src sets (name prefix port_src_)
local setline setname
while IFS= read -r setline; do
setname=$(echo "$setline" | sed -n 's/.*set \(port_src_[a-zA-Z0-9_-]*\).*/\1/p')
[[ -n "$setname" ]] || continue
nft delete set "$table" "$name" "$setname" 2>/dev/null || true
done < <(nft list table "$table" "$name" 2>/dev/null | grep -E 'set port_src_' || true)
local cmds_file
cmds_file=$(mktemp)
python3 - "$PORT_RULES_FILE" >"$cmds_file" <<'PY'
import json, sys, re
path = sys.argv[1]
try:
rules = json.load(open(path, encoding="utf-8"))
except Exception:
rules = []
safe_id = re.compile(r"[^a-zA-Z0-9_]")
for r in rules:
rid = safe_id.sub("_", str(r.get("id") or "x"))[:40]
action = r.get("action") or "open"
proto = r.get("protocol") or "tcp"
if proto not in ("tcp", "udp"):
continue
ps = int(r.get("port_start") or 0)
pe = int(r.get("port_end") or ps)
if ps < 1 or pe > 65535 or pe < ps:
continue
cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
if not cidrs:
continue
verdict = "drop" if action == "close" else "accept"
dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
comment = f"evofw-port-{rid}"
is_all = any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs)
if is_all:
print(f'nft add rule inet evofw input {proto} dport {dport} counter {verdict} comment "{comment}"')
continue
setname = f"port_src_{rid}"
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
chunk = []
for c in cidrs:
chunk.append(c)
if len(chunk) >= 32:
joined = ", ".join(chunk)
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
chunk = []
if chunk:
joined = ", ".join(chunk)
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
print(
f'nft add rule inet evofw input ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"'
)
PY
local cmd
while IFS= read -r cmd; do
[[ -n "$cmd" ]] || continue
# shellcheck disable=SC2086
eval "$cmd" 2>>"$LOG_FILE" || log "nft port ACL cmd failed: $cmd"
done <"$cmds_file"
rm -f "$cmds_file"
}
# Collect observed host firewall rules + listeners (best-effort).
collect_host_firewall() {
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
if ! command -v python3 >/dev/null 2>&1; then
return 0
fi
local nft_txt="" ipt_txt="" ufw_txt="" fwd_txt="" ss_txt=""
nft_txt=$(nft list ruleset 2>/dev/null || true)
ipt_txt=$(iptables-save 2>/dev/null || true)
if command -v ufw >/dev/null 2>&1; then
ufw_txt=$(ufw status verbose 2>/dev/null || true)
fi
if command -v firewall-cmd >/dev/null 2>&1; then
fwd_txt=$(firewall-cmd --list-all 2>/dev/null || true)
fi
ss_txt=$(ss -lntu 2>/dev/null || true)
HOST_FIREWALL_JSON=$(NFT_TXT="$nft_txt" IPT_TXT="$ipt_txt" UFW_TXT="$ufw_txt" FWD_TXT="$fwd_txt" SS_TXT="$ss_txt" python3 - <<'PY'
import json, os, re
def ownership_of(text: str) -> str:
t = text.lower()
if "evofw" in t or "evofw-port-" in t:
return "evofw"
return "foreign"
rules = []
listeners = []
nft = os.environ.get("NFT_TXT") or ""
# Rough nft rule lines
cur_table = ""
cur_chain = ""
for line in nft.splitlines():
ls = line.strip()
if ls.startswith("table "):
cur_table = ls
cur_chain = ""
continue
m = re.match(r"chain\s+(\S+)", ls)
if m:
cur_chain = m.group(1)
continue
if not ls or ls.startswith("type ") or ls.startswith("policy ") or ls.startswith("set ") or ls.startswith("map "):
continue
if "accept" in ls or "drop" in ls or "reject" in ls or "jump " in ls or "goto " in ls:
act = "accept" if " accept" in f" {ls}" or ls.endswith("accept") else (
"drop" if " drop" in f" {ls}" or ls.endswith("drop") else (
"reject" if "reject" in ls else "other"
)
)
proto = ""
if " tcp " in f" {ls}" or ls.startswith("tcp "):
proto = "tcp"
elif " udp " in f" {ls}" or ls.startswith("udp "):
proto = "udp"
dport = ""
m = re.search(r"dport\s+(\S+)", ls)
if m:
dport = m.group(1)
saddr = ""
m = re.search(r"saddr\s+(\S+)", ls)
if m:
saddr = m.group(1).lstrip("@")
raw = ls[:500]
rules.append({
"ownership": ownership_of(cur_table + " " + cur_chain + " " + raw),
"backend": "nft",
"table": cur_table[:120],
"chain": cur_chain[:120],
"action": act,
"protocol": proto or None,
"dport": dport or None,
"saddr": saddr or None,
"raw": raw,
})
ipt = os.environ.get("IPT_TXT") or ""
cur_chain = ""
for line in ipt.splitlines():
if line.startswith(":"):
cur_chain = line[1:].split()[0] if line[1:] else ""
continue
if not line.startswith("-A "):
continue
parts = line.split(None, 2)
chain = parts[1] if len(parts) > 1 else ""
rest = parts[2] if len(parts) > 2 else line
act = "DROP" if " -j DROP" in line else (
"ACCEPT" if " -j ACCEPT" in line else (
"REJECT" if " -j REJECT" in line else "other"
)
)
proto = ""
m = re.search(r"-p\s+(\w+)", line)
if m:
proto = m.group(1)
dport = ""
m = re.search(r"--dport(?:s)?\s+(\S+)", line)
if m:
dport = m.group(1)
saddr = ""
m = re.search(r"-s\s+(\S+)", line)
if m:
saddr = m.group(1)
raw = line[:500]
rules.append({
"ownership": ownership_of(raw),
"backend": "iptables",
"chain": chain[:120],
"action": act.lower() if isinstance(act, str) else act,
"protocol": proto or None,
"dport": dport or None,
"saddr": saddr or None,
"raw": raw,
})
ufw = os.environ.get("UFW_TXT") or ""
for line in ufw.splitlines():
ls = line.strip()
if not ls or ls.startswith("Status") or ls.startswith("Logging") or ls.startswith("Default") or ls.startswith("To") or ls.startswith("--"):
continue
if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls:
rules.append({
"ownership": ownership_of(ls),
"backend": "ufw",
"action": "allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
"raw": ls[:500],
})
fwd = os.environ.get("FWD_TXT") or ""
for line in fwd.splitlines():
ls = line.strip()
if not ls:
continue
if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls:
rules.append({
"ownership": "foreign",
"backend": "firewalld",
"raw": ls[:500],
})
ss = os.environ.get("SS_TXT") or ""
for line in ss.splitlines()[1:]:
parts = line.split()
if len(parts) < 5:
continue
proto = parts[0]
local = parts[4]
# *:22 or 0.0.0.0:22 or [::]:22
m = re.search(r"([^:]+):(\d+)$", local)
if not m:
# IPv6 [::]:port
m = re.search(r"\[([^\]]+)\]:(\d+)$", local)
if not m:
continue
addr, port_s = m.group(1), m.group(2)
else:
addr, port_s = m.group(1), m.group(2)
try:
port = int(port_s)
except ValueError:
continue
listeners.append({
"protocol": "tcp" if proto.startswith("tcp") else ("udp" if proto.startswith("udp") else proto),
"port": port,
"address": addr,
})
# Cap
rules = rules[:500]
listeners = listeners[:200]
print(json.dumps({"rules": rules, "listeners": listeners}, separators=(",", ":")))
PY
) || HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
}
ensure_ipset_counters() {
local name=$1
if ! ipset list "$name" >/dev/null 2>&1; then
@@ -436,9 +695,33 @@ send_report() {
if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then
collect_port_hits
fi
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
collect_host_firewall
fi
local report
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}")
# Compose report with python to safely embed host_firewall JSON
if command -v python3 >/dev/null 2>&1; then
report=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \
METHOD="${KERNEL_METHOD:-$BACKEND}" IP_HITS="${IP_HITS_JSON:-[]}" PORT_HITS="${PORT_HITS_JSON:-[]}" \
HOST_FW="${HOST_FIREWALL_JSON}" python3 - <<'PY'
import json, os
print(json.dumps({
"status": "ok",
"prefix_count": int(os.environ.get("APPLIED") or 0),
"packets_dropped": int(os.environ.get("DROPPED") or 0),
"packets_accepted": int(os.environ.get("ACCEPTED") or 0),
"kernel_method": os.environ.get("METHOD") or "auto",
"source": "agent",
"ip_hits": json.loads(os.environ.get("IP_HITS") or "[]"),
"port_hits": json.loads(os.environ.get("PORT_HITS") or "[]"),
"host_firewall": json.loads(os.environ.get("HOST_FW") or '{"rules":[],"listeners":[]}'),
}, separators=(",", ":")))
PY
)
else
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}")
fi
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
@@ -468,6 +751,8 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
fi
collect_host_firewall
HOST_FW_CAPTURED=1
send_report
exit 0
fi
@@ -486,6 +771,8 @@ elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&
PORT_HITS_JSON="[]"
PORT_HITS_CAPTURED=1
fi
collect_host_firewall
HOST_FW_CAPTURED=1
case "$BACKEND" in
nft|auto)