feat(api, web): implement port ACL and host firewall snapshot features
- 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user