feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
if ! curl_policy "$POLICY_FILE"; then
|
||||
rc=$?
|
||||
[[ "$rc" == "2" ]] && exit 0
|
||||
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"
|
||||
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[@]}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
||||
for p in "${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[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done
|
||||
for p in "${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
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# EvoFirewall Linux install one-liner
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "evofw install: run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in curl bash; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq jq || true
|
||||
fi
|
||||
fi
|
||||
|
||||
: "${EVOFW_CP_URL:?EVOFW_CP_URL required}"
|
||||
: "${EVOFW_SEED:?EVOFW_SEED required}"
|
||||
: "${EVOFW_CLIENT_NAME:?EVOFW_CLIENT_NAME required}"
|
||||
|
||||
CONF_DIR=/etc/evofw
|
||||
CONF_FILE="${CONF_DIR}/agent.conf"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
|
||||
PLATFORM="${EVOFW_PLATFORM:-linux}"
|
||||
|
||||
if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
echo "Already installed ($CONF_FILE). Set EVOFW_INSTALL_FORCE=1 to reinstall." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gen_token() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
echo -n "evofw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
|
||||
else
|
||||
echo -n "evofw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
|
||||
fi
|
||||
}
|
||||
|
||||
CLIENT_TOKEN="$(gen_token)"
|
||||
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
||||
CP_URL="${EVOFW_CP_URL%/}"
|
||||
|
||||
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \
|
||||
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
|
||||
|
||||
ENROLL_TMP=$(mktemp)
|
||||
trap 'rm -f "$ENROLL_TMP"' EXIT
|
||||
ENROLL_CODE=$(curl -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}" \
|
||||
-d "$ENROLL_BODY")
|
||||
if [[ "$ENROLL_CODE" != "201" && "$ENROLL_CODE" != "200" ]]; then
|
||||
echo "enroll failed: HTTP ${ENROLL_CODE}" >&2
|
||||
cat "$ENROLL_TMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
RESP=$(cat "$ENROLL_TMP")
|
||||
CLIENT_ID=""
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id // .id')
|
||||
else
|
||||
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
|
||||
mkdir -p "$CONF_DIR"
|
||||
chmod 700 "$CONF_DIR"
|
||||
cat >"$CONF_FILE" <<EOF
|
||||
EVOFW_CP_URL=${CP_URL}
|
||||
CLIENT_ID=${CLIENT_ID}
|
||||
CLIENT_TOKEN=${CLIENT_TOKEN}
|
||||
CLIENT_NAME=${EVOFW_CLIENT_NAME}
|
||||
KERNEL_BACKEND=auto
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
|
||||
curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$SYNC_SCRIPT"
|
||||
chmod 755 "$SYNC_SCRIPT"
|
||||
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
BACKEND=nft
|
||||
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=ipset
|
||||
elif command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=iptables
|
||||
else
|
||||
echo "no supported firewall backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
|
||||
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
|
||||
|
||||
INTERVAL="${EVOFW_SYNC_INTERVAL:-1min}"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
cat >/etc/systemd/system/evofw-firewall.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=EvoFirewall sync
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/evofw-firewall.sh
|
||||
UNIT
|
||||
cat >/etc/systemd/system/evofw-firewall.timer <<UNIT
|
||||
[Unit]
|
||||
Description=EvoFirewall sync timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=${INTERVAL}
|
||||
AccuracySec=5s
|
||||
Unit=evofw-firewall.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now evofw-firewall.timer
|
||||
else
|
||||
(crontab -l 2>/dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
|
||||
fi
|
||||
|
||||
echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI, then: $SYNC_SCRIPT"
|
||||
@@ -0,0 +1,46 @@
|
||||
# EvoFirewall MikroTik install (RouterOS 7+)
|
||||
# Usage: import after setting globals, or paste into terminal.
|
||||
# Required globals before import (or edit below):
|
||||
# :global EvofwCpUrl "https://fw.example.com"
|
||||
# :global EvofwSeed "YOUR_SEED"
|
||||
# :global EvofwName "mt-01"
|
||||
|
||||
:global EvofwCpUrl
|
||||
:global EvofwSeed
|
||||
:global EvofwName
|
||||
|
||||
:if ([:typeof $EvofwCpUrl] = "nothing") do={ :error "EvofwCpUrl required" }
|
||||
:if ([:typeof $EvofwSeed] = "nothing") do={ :error "EvofwSeed required" }
|
||||
:if ([:typeof $EvofwName] = "nothing") do={ :set EvofwName [/system identity get name] }
|
||||
|
||||
:local token ("evofw_" . [/certificate scep-server nonce generate])
|
||||
:if ([:len $token] < 20) do={
|
||||
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]])
|
||||
}
|
||||
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"}")
|
||||
|
||||
/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
|
||||
|
||||
# Persist credentials for scheduler script
|
||||
/system script remove [find name="evofw-env"]
|
||||
/system script add name=evofw-env source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ")
|
||||
|
||||
/system script remove [find name="evofw-sync"]
|
||||
/system script add name=evofw-sync policy=read,write,policy,test source={
|
||||
:global EvofwCpUrl
|
||||
:global EvofwToken
|
||||
:if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ /system script run evofw-env }
|
||||
:local tmp [/file get [find name="evofw-policy.json"] name]
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.json
|
||||
# Address-lists: EVOFW_DENY / EVOFW_ALLOW — operator should map filter rules once:
|
||||
# /ip firewall filter add chain=input src-address-list=EVOFW_DENY action=drop comment=evofw
|
||||
# whitelist: policy drop + accept EVOFW_ALLOW
|
||||
:log info "evofw: policy fetched — apply address-lists via controller export or manual parse"
|
||||
/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
|
||||
}
|
||||
|
||||
/system scheduler remove [find name="evofw-sync"]
|
||||
/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync
|
||||
|
||||
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI, ensure filter rules for EVOFW_* lists")
|
||||
Reference in New Issue
Block a user