#!/usr/bin/env bash # EvoFirewall Linux sync agent — nft / ipset / iptables set -euo pipefail CONF_FILE=/etc/evofw/agent.conf LOG_FILE=/var/log/evofw-firewall.log STATE_DIR=/var/lib/evofw HASH_FILE="${STATE_DIR}/last_hash" POLICY_FILE="${STATE_DIR}/last_policy.json" log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; } if [[ ! -f "$CONF_FILE" ]]; then log "missing $CONF_FILE" exit 1 fi # shellcheck disable=SC1090 source "$CONF_FILE" : "${EVOFW_CP_URL:?}" : "${CLIENT_TOKEN:?}" CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}" CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}" BACKEND="${KERNEL_BACKEND:-auto}" mkdir -p "$STATE_DIR" curl_policy() { local dest="$1" local code code=$(curl -sS -o "$dest" -w "%{http_code}" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Accept: application/json" \ "${EVOFW_CP_URL%/}/v1/agent/policy") || return 1 if [[ "$code" == "403" ]]; then log "pending approval" return 2 fi if [[ "$code" != "200" ]]; then log "policy HTTP $code" return 1 fi return 0 } # Do not use `if ! cmd; rc=$?` — after `!`, $? is 0, not the real status. policy_rc=0 curl_policy "$POLICY_FILE" || policy_rc=$? if [[ "$policy_rc" -eq 2 ]]; then exit 0 fi if [[ "$policy_rc" -ne 0 ]]; then exit 1 fi parse_policy() { local f="$1" if command -v jq >/dev/null 2>&1; then HASH=$(jq -r '.hash // empty' "$f") MODE=$(jq -r '.policy_mode // "blacklist"' "$f") mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f") mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f") return 0 fi if command -v python3 >/dev/null 2>&1; then eval "$(python3 - "$f" <<'PY' import json,sys d=json.load(open(sys.argv[1],encoding="utf-8")) print(f'HASH={d.get("hash") or ""}') print(f'MODE={d.get("policy_mode") or "blacklist"}') 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 []))+")") PY )" return 0 fi log "need jq or python3" exit 1 } HASH=""; MODE=blacklist; DENY=(); ALLOW=() parse_policy "$POLICY_FILE" # Empty deny/allow is valid — agent may have no rule sets yet. DENY=("${DENY[@]+"${DENY[@]}"}") ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}") log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH" PACKETS_DROPPED=0 PACKETS_ACCEPTED=0 KERNEL_METHOD="" APPLIED=0 nft_join() { local out="" p for p in "$@"; do [[ -n "$out" ]] && out+=", " out+="$p" done printf '%s' "$out" } nft_add_chunk() { local table=$1 name=$2 setname=$3 shift 3 local joined; joined=$(nft_join "$@") nft add element "$table" "$name" "$setname" "{ ${joined} }" 2>>"$LOG_FILE" || { for p in "$@"; do nft add element "$table" "$name" "$setname" "{ $p }" 2>>"$LOG_FILE" || true; done } } collect_nft_stats() { PACKETS_DROPPED=0; PACKETS_ACCEPTED=0 local line while IFS= read -r line; do if [[ "$line" == *drop* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then PACKETS_DROPPED="${BASH_REMATCH[1]}" elif [[ "$line" == *accept* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then PACKETS_ACCEPTED="${BASH_REMATCH[1]}" fi done < <(nft list chain inet evofw input 2>/dev/null || true) } apply_nft() { local table=inet name=evofw local deny_v4=() allow_v4=() p for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name" nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \ nft add set "$table" "$name" deny_v4 '{ type ipv4_addr; flags interval; }' nft list set "$table" "$name" allow_v4 >/dev/null 2>&1 || \ nft add set "$table" "$name" allow_v4 '{ type ipv4_addr; flags interval; }' nft flush set "$table" "$name" deny_v4 nft flush set "$table" "$name" allow_v4 local batch=() chunk=64 for p in "${deny_v4[@]}"; do batch+=("$p") if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"; batch=(); fi done ((${#batch[@]})) && nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}" batch=() for p in "${allow_v4[@]}"; do batch+=("$p") if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"; batch=(); fi done ((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" nft delete chain "$table" "$name" input 2>/dev/null || true if [[ "$MODE" == "whitelist" ]]; then nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' nft add rule "$table" "$name" input ct state established,related counter accept nft add rule "$table" "$name" input iif lo counter accept nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept nft add rule "$table" "$name" input counter drop else nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop nft add rule "$table" "$name" input counter accept fi KERNEL_METHOD=nft APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]})) } apply_ipset() { local dset=evofw_deny_v4 aset=evofw_allow_v4 ipset list "$dset" >/dev/null 2>&1 || ipset create "$dset" hash:net family inet ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet ipset flush "$dset"; ipset flush "$aset" local p n=0 for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true if [[ "$MODE" == "whitelist" ]]; then iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT iptables -A INPUT -j DROP 2>/dev/null || true else iptables -I INPUT -m set --match-set "$dset" src -j DROP fi KERNEL_METHOD=ipset APPLIED=$n } send_report() { if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then collect_nft_stats fi local report report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \ "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}") curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Content-Type: application/json" \ -d "$report" >/dev/null 2>&1 || true curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"source":"agent"}' >/dev/null 2>&1 || true } if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then log "unchanged hash $HASH — skip apply" KERNEL_METHOD="${BACKEND}" send_report exit 0 fi case "$BACKEND" in nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft elif command -v ipset >/dev/null 2>&1; then apply_ipset else log "no backend"; exit 1; fi ;; ipset) apply_ipset ;; *) apply_nft ;; esac echo "$HASH" >"$HASH_FILE" log "applied mode=$MODE count=$APPLIED method=$KERNEL_METHOD" send_report