feat: migrate policy firewall from iptables/ipset to nftables
- Rewrite wg-policy-lib.sh: replace ipset functions with nft helpers, add backup_nftables() - Rewrite wg-policy-engine.sh: generate nft ruleset file, atomic load via nft -f, rollback support - Simplify wg-policy-cleanup.sh: single nft delete table inet wg_policy - Update wg-policy-ctl: nft commands for rules/ipset/stats/backup - Rebuild install.sh: nftables dependency, WireGuard pre-check, PostUp/PostDown auto-integration - Update build.sh/build.bat: match install.sh changes - Update README.md: nftables prerequisites and references
This commit is contained in:
+177
-232
@@ -1,55 +1,152 @@
|
||||
#!/bin/bash
|
||||
# wg-policy-engine.sh — Applies iptables/ipset rules from policy.json
|
||||
# Fixed: unquoted variables, LOG placement, LAN block targeting,
|
||||
# atomic chain swap, ipset, IPv6 optional, rollback on failure
|
||||
# wg-policy-engine.sh — Applies nftables rules from policy.json
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/wg-policy-lib.sh"
|
||||
|
||||
NFT_FILE="/tmp/wg-policy.nft"
|
||||
NFT_BACKUP="/tmp/wg-policy-backup.nft"
|
||||
|
||||
# ============================================================
|
||||
# ROLLBACK
|
||||
# ============================================================
|
||||
|
||||
rollback() {
|
||||
log_error "ROLLBACK triggered! Restoring previous rules..."
|
||||
log_error "ROLLBACK triggered! Restoring previous ruleset..."
|
||||
trap - ERR
|
||||
|
||||
# Remove new chain references
|
||||
while true; do
|
||||
local rline=""
|
||||
rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
|
||||
if [[ -n "$rline" ]]; then
|
||||
iptables -D FORWARD "$rline" 2>/dev/null || break
|
||||
nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true
|
||||
|
||||
if [[ -f "$NFT_BACKUP" ]]; then
|
||||
if nft -f "$NFT_BACKUP" 2>/dev/null; then
|
||||
log_info "Rollback: restored from backup"
|
||||
else
|
||||
break
|
||||
log_error "Rollback: failed to restore from backup"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Flush and remove new chain
|
||||
iptables -F "$CHAIN" 2>/dev/null || true
|
||||
iptables -X "$CHAIN" 2>/dev/null || true
|
||||
# ============================================================
|
||||
# RULESET GENERATION
|
||||
# ============================================================
|
||||
|
||||
# Restore backup chain if it exists
|
||||
if iptables -L "$CHAIN_BACKUP" -n &>/dev/null; then
|
||||
# Rename backup chain to active
|
||||
iptables -N "$CHAIN" 2>/dev/null || iptables -F "$CHAIN"
|
||||
# Copy rules from backup
|
||||
iptables-save -c | grep "^-A $CHAIN_BACKUP" | \
|
||||
sed "s/-A $CHAIN_BACKUP/-A $CHAIN/" | \
|
||||
iptables-restore -c 2>/dev/null || true
|
||||
generate_ruleset() {
|
||||
local WG_SUBNET="$1"
|
||||
local WG_SUBNET_V6="$2"
|
||||
local LAN_SUBNETS="$3"
|
||||
local DEF_IF="$4"
|
||||
|
||||
iptables -A FORWARD -i "$WG_IF" -j "$CHAIN"
|
||||
log_info "Rollback: restored from backup chain"
|
||||
cat > "$NFT_FILE" << 'HEADER'
|
||||
#!/usr/sbin/nft -f
|
||||
flush ruleset
|
||||
|
||||
table inet wg_policy {
|
||||
|
||||
set wg_allowed_v4 {
|
||||
type ipv4_addr . ipv4_addr
|
||||
flags interval
|
||||
}
|
||||
|
||||
set wg_allowed_v6 {
|
||||
type ipv6_addr . ipv6_addr
|
||||
flags interval
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
|
||||
ct state established,related accept
|
||||
|
||||
iifname "wg0" ip saddr . ip daddr @wg_allowed_v4 accept
|
||||
iifname "wg0" ip6 saddr . ip6 daddr @wg_allowed_v6 accept
|
||||
|
||||
HEADER
|
||||
|
||||
# Client isolation (IPv4)
|
||||
if [[ -n "$WG_SUBNET" ]]; then
|
||||
cat >> "$NFT_FILE" << EOF
|
||||
|
||||
/* isolation: WG client to WG client */
|
||||
iifname "wg0" ip saddr $WG_SUBNET ip daddr $WG_SUBNET ct state new drop
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Cleanup backup chain
|
||||
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true
|
||||
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true
|
||||
# Client isolation (IPv6)
|
||||
if [[ -n "$WG_SUBNET_V6" ]]; then
|
||||
cat >> "$NFT_FILE" << EOF
|
||||
|
||||
# Cleanup backup ipsets
|
||||
destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true
|
||||
destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true
|
||||
/* IPv6 isolation */
|
||||
iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr $WG_SUBNET_V6 ct state new drop
|
||||
iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr fe80::/10 drop
|
||||
iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr fc00::/7 drop
|
||||
EOF
|
||||
fi
|
||||
|
||||
# LAN block (IPv4)
|
||||
if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then
|
||||
local lan_list=""
|
||||
while IFS= read -r subnet; do
|
||||
[[ -z "$subnet" ]] && continue
|
||||
[[ "$subnet" == "$WG_SUBNET" ]] && continue
|
||||
if [[ -n "$lan_list" ]]; then
|
||||
lan_list+=", $subnet"
|
||||
else
|
||||
lan_list="$subnet"
|
||||
fi
|
||||
done <<< "$LAN_SUBNETS"
|
||||
|
||||
if [[ -n "$lan_list" ]]; then
|
||||
cat >> "$NFT_FILE" << EOF
|
||||
|
||||
/* LAN block */
|
||||
iifname "wg0" ip saddr $WG_SUBNET ip daddr { $lan_list } drop
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# Internet access per client (inline in chain)
|
||||
while IFS= read -r client_ip; do
|
||||
[[ -z "$client_ip" ]] && continue
|
||||
cat >> "$NFT_FILE" << EOF
|
||||
|
||||
/* internet access: $client_ip */
|
||||
iifname "wg0" ip saddr $client_ip accept
|
||||
EOF
|
||||
done < <(jq -r '
|
||||
.clients // {} | to_entries[] |
|
||||
select(.value.internet == "true") |
|
||||
.key
|
||||
' "$POLICY_FILE" 2>/dev/null)
|
||||
|
||||
while IFS= read -r client_ip; do
|
||||
[[ -z "$client_ip" ]] && continue
|
||||
cat >> "$NFT_FILE" << EOF
|
||||
|
||||
/* internet access v6: $client_ip */
|
||||
iifname "wg0" ip6 saddr $client_ip accept
|
||||
EOF
|
||||
done < <(jq -r '
|
||||
.clients // {} | to_entries[] |
|
||||
select(.value.internet == "true") |
|
||||
select(.key | test(":")) |
|
||||
.key
|
||||
' "$POLICY_FILE" 2>/dev/null)
|
||||
|
||||
# Log + drop (final rule)
|
||||
cat >> "$NFT_FILE" << EOF
|
||||
|
||||
/* log + drop */
|
||||
iifname "wg0" limit rate ${LOG_RATE} log prefix "${LOG_PREFIX}: " drop
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname "${DEF_IF}" masquerade
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
@@ -75,8 +172,8 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup iptables state
|
||||
backup_iptables
|
||||
# Backup current nftables state
|
||||
backup_nftables
|
||||
|
||||
# Set trap for rollback on failure
|
||||
trap 'rollback' ERR
|
||||
@@ -105,221 +202,69 @@ main() {
|
||||
fi
|
||||
|
||||
# === BASE ROUTING & NAT ===
|
||||
# Enable IP Forwarding
|
||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||
if command -v ip6tables &>/dev/null; then
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
|
||||
|
||||
# === BACKUP EXISTING TABLE ===
|
||||
if nft list table "$NFT_TABLE_FULL" &>/dev/null; then
|
||||
nft list table "$NFT_TABLE_FULL" > "$NFT_BACKUP" 2>/dev/null || true
|
||||
log_info "Backed up existing table to $NFT_BACKUP"
|
||||
fi
|
||||
|
||||
# Setup MASQUERADE on default interface
|
||||
if ! iptables -t nat -C POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; then
|
||||
iptables -t nat -A POSTROUTING -o "$DEF_IF" -j MASQUERADE
|
||||
log_info "Enabled IPv4 MASQUERADE on $DEF_IF"
|
||||
fi
|
||||
# === DELETE OLD TABLE ===
|
||||
nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true
|
||||
|
||||
# === CLEANUP OLD CHAIN (loop until all references removed) ===
|
||||
log_info "Cleaning up old chain references..."
|
||||
while true; do
|
||||
local rline=""
|
||||
rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
|
||||
if [[ -n "$rline" ]]; then
|
||||
iptables -D FORWARD "$rline" 2>/dev/null || break
|
||||
# === GENERATE AND LOAD NEW RULESET ===
|
||||
generate_ruleset "$WG_SUBNET" "$WG_SUBNET_V6" "$LAN_SUBNETS" "$DEF_IF"
|
||||
log_info "Generated ruleset: $NFT_FILE"
|
||||
|
||||
nft -f "$NFT_FILE"
|
||||
log_info "Loaded nftables ruleset from $NFT_FILE"
|
||||
|
||||
# === POPULATE SETS ===
|
||||
log_info "Populating whitelist sets..."
|
||||
|
||||
local v4_count=0
|
||||
local v6_count=0
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
local client_ip target
|
||||
client_ip=$(echo "$line" | awk '{print $1}')
|
||||
target=$(echo "$line" | awk '{print $2}')
|
||||
[[ -z "$client_ip" || -z "$target" ]] && continue
|
||||
|
||||
if [[ "$target" == *":"* ]]; then
|
||||
nft add element "$NFT_TABLE_FULL" "$NFT_SET_V6" { "$client_ip" . "$target" } 2>/dev/null || \
|
||||
log_warn "Failed to add ${client_ip} . ${target} to set $NFT_SET_V6"
|
||||
(( v6_count++ )) || true
|
||||
else
|
||||
break
|
||||
nft add element "$NFT_TABLE_FULL" "$NFT_SET_V4" { "$client_ip" . "$target" } 2>/dev/null || \
|
||||
log_warn "Failed to add ${client_ip} . ${target} to set $NFT_SET_V4"
|
||||
(( v4_count++ )) || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Backup existing chain before flushing
|
||||
if iptables -L "$CHAIN" -n &>/dev/null; then
|
||||
iptables -N "$CHAIN_BACKUP" 2>/dev/null || iptables -F "$CHAIN_BACKUP"
|
||||
iptables-save -c 2>/dev/null | grep "^-A $CHAIN" | \
|
||||
sed "s/-A $CHAIN/-A $CHAIN_BACKUP/" | \
|
||||
iptables-restore -c 2>/dev/null || true
|
||||
log_info "Backed up existing chain to $CHAIN_BACKUP"
|
||||
fi
|
||||
|
||||
iptables -F "$CHAIN" 2>/dev/null || true
|
||||
iptables -X "$CHAIN" 2>/dev/null || true
|
||||
|
||||
# === CREATE FRESH CHAIN ===
|
||||
iptables -N "$CHAIN"
|
||||
|
||||
if ! iptables -C FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; then
|
||||
iptables -I FORWARD 1 -i "$WG_IF" -j "$CHAIN"
|
||||
fi
|
||||
|
||||
if ! iptables -C FORWARD -o "$WG_IF" -j "$CHAIN" 2>/dev/null; then
|
||||
iptables -I FORWARD 2 -o "$WG_IF" -j "$CHAIN"
|
||||
fi
|
||||
log_info "Chain $CHAIN created and linked to FORWARD (In/Out)"
|
||||
|
||||
# === POPULATE IPSET (hash:net,net for source->target mapping) ===
|
||||
local use_ipset=false
|
||||
if has_ipset; then
|
||||
use_ipset=true
|
||||
log_info "Populating ipsets..."
|
||||
|
||||
ensure_ipset "$IPSET_V4" "inet"
|
||||
flush_ipset "$IPSET_V4"
|
||||
|
||||
# Check if we need IPv6 ipset
|
||||
local use_ipv6=false
|
||||
if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then
|
||||
use_ipv6=true
|
||||
ensure_ipset "$IPSET_V6" "inet6"
|
||||
flush_ipset "$IPSET_V6"
|
||||
fi
|
||||
|
||||
# Read all access entries and populate ipset (client_ip,target)
|
||||
jq -r '
|
||||
.clients // {} | to_entries[] |
|
||||
select(.value.access != null and (.value.access | length > 0)) |
|
||||
.key as $ip |
|
||||
.value.access[] |
|
||||
"\($ip) \(.)"
|
||||
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do
|
||||
[[ -z "$client_ip" || -z "$target" ]] && continue
|
||||
|
||||
if [[ "$target" == *":"* ]]; then
|
||||
if [[ "$use_ipv6" == true ]]; then
|
||||
ipset add "$IPSET_V6" "${client_ip},${target}" 2>/dev/null || \
|
||||
log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V6"
|
||||
fi
|
||||
else
|
||||
ipset add "$IPSET_V4" "${client_ip},${target}" 2>/dev/null || \
|
||||
log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V4"
|
||||
fi
|
||||
done
|
||||
|
||||
local v4_count v6_count
|
||||
v4_count=$(ipset list "$IPSET_V4" 2>/dev/null | grep -c '^[0-9]' || echo 0)
|
||||
v6_count=$(ipset list "$IPSET_V6" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0)
|
||||
log_info "ipset $IPSET_V4: $v4_count entries, $IPSET_V6: $v6_count entries"
|
||||
else
|
||||
log_warn "ipset not installed, falling back to per-rule iptables whitelist"
|
||||
local use_ipv6=false
|
||||
if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then
|
||||
use_ipv6=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# === RULE 1: ESTABLISHED,RELATED — allow return traffic ===
|
||||
iptables -A "$CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# === RULE 2: WHITELIST (per-client source) ===
|
||||
if [[ "$use_ipset" == true ]]; then
|
||||
iptables -A "$CHAIN" -m set --match-set "$IPSET_V4" src,dst -j ACCEPT
|
||||
if [[ "$use_ipv6" == true ]]; then
|
||||
ip6tables -A "$CHAIN" -m set --match-set "$IPSET_V6" src,dst -j ACCEPT 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
jq -r '
|
||||
.clients // {} | to_entries[] |
|
||||
select(.value.access != null and (.value.access | length > 0)) |
|
||||
.key as $ip |
|
||||
.value.access[] |
|
||||
"\($ip) \(.)"
|
||||
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do
|
||||
[[ -z "$client_ip" || -z "$target" ]] && continue
|
||||
|
||||
if [[ "$client_ip" == *":"* ]]; then
|
||||
if [[ "$use_ipv6" == true ]]; then
|
||||
ip6tables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
iptables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# === RULE 3: ISOLATION — drop NEW connections between WG clients ===
|
||||
if [[ -n "$WG_SUBNET" ]]; then
|
||||
iptables -A "$CHAIN" \
|
||||
-s "$WG_SUBNET" \
|
||||
-d "$WG_SUBNET" \
|
||||
-m conntrack --ctstate NEW \
|
||||
-j DROP
|
||||
log_info "Client isolation enabled for $WG_SUBNET"
|
||||
fi
|
||||
|
||||
if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then
|
||||
ip6tables -A "$CHAIN" \
|
||||
-s "$WG_SUBNET_V6" \
|
||||
-d "$WG_SUBNET_V6" \
|
||||
-m conntrack --ctstate NEW \
|
||||
-j DROP 2>/dev/null || true
|
||||
log_info "Client isolation enabled for IPv6 $WG_SUBNET_V6"
|
||||
fi
|
||||
|
||||
# === RULE 4: BLOCK LAN — drop from WG subnet to private LAN ===
|
||||
if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then
|
||||
echo "$LAN_SUBNETS" | while read -r subnet; do
|
||||
[[ -z "$subnet" ]] && continue
|
||||
# Skip if LAN subnet exactly matches WG subnet (handled by Rule 3)
|
||||
[[ "$subnet" == "$WG_SUBNET" ]] && continue
|
||||
|
||||
iptables -A "$CHAIN" -s "$WG_SUBNET" -d "$subnet" -j DROP
|
||||
log_info "Block: $WG_SUBNET → $subnet"
|
||||
done
|
||||
fi
|
||||
|
||||
# IPv6 LAN block (link-local and ULA)
|
||||
if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then
|
||||
# Block to link-local (fe80::/10)
|
||||
ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fe80::/10" -j DROP 2>/dev/null || true
|
||||
# Block to ULA (fc00::/7)
|
||||
ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fc00::/7" -j DROP 2>/dev/null || true
|
||||
log_info "IPv6 LAN block applied (link-local + ULA)"
|
||||
fi
|
||||
|
||||
# === RULE 5: INTERNET ACCESS (#Internet = true) ===
|
||||
jq -r '
|
||||
done < <(jq -r '
|
||||
.clients // {} | to_entries[] |
|
||||
select(.value.internet == "true") |
|
||||
"\(.key)"
|
||||
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip; do
|
||||
[[ -z "$client_ip" ]] && continue
|
||||
select(.value.access != null and (.value.access | length > 0)) |
|
||||
.key as $ip |
|
||||
.value.access[] |
|
||||
"\($ip) \(.)"
|
||||
' "$POLICY_FILE" 2>/dev/null)
|
||||
|
||||
if [[ "$client_ip" == *":"* ]]; then
|
||||
if [[ "$use_ipv6" == true ]]; then
|
||||
ip6tables -A "$CHAIN" -s "$client_ip" -j ACCEPT 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
iptables -A "$CHAIN" -s "$client_ip" -j ACCEPT
|
||||
fi
|
||||
done
|
||||
log_info "Set $NFT_SET_V4: $v4_count entries, $NFT_SET_V6: $v6_count entries"
|
||||
|
||||
# === RULE 6: LOGGING (rate-limited) — BEFORE final DROP ===
|
||||
iptables -A "$CHAIN" \
|
||||
-m limit --limit "$LOG_RATE" \
|
||||
-j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4
|
||||
|
||||
if [[ "$use_ipv6" == true ]]; then
|
||||
ip6tables -A "$CHAIN" \
|
||||
-m limit --limit "$LOG_RATE" \
|
||||
-j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# === RULE 7: DEFAULT DROP (internet block by default) ===
|
||||
iptables -A "$CHAIN" -j DROP
|
||||
|
||||
if [[ "$use_ipv6" == true ]]; then
|
||||
ip6tables -A "$CHAIN" -j DROP 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# === CLEANUP BACKUP CHAIN (no rollback needed anymore) ===
|
||||
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true
|
||||
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true
|
||||
# === CLEANUP BACKUP (success path) ===
|
||||
rm -f "$NFT_BACKUP" 2>/dev/null || true
|
||||
|
||||
# Disable ERR trap (success path)
|
||||
trap - ERR
|
||||
|
||||
# === VERIFY ===
|
||||
local rule_count
|
||||
rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l)
|
||||
log_info "Policy applied. Chain: $CHAIN, Rules: $rule_count"
|
||||
rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0)
|
||||
log_info "Policy applied. Table: $NFT_TABLE, Rules: $rule_count"
|
||||
|
||||
echo "[OK] iptables policy applied. Chain: $CHAIN"
|
||||
echo "[OK] nftables policy applied. Table: $NFT_TABLE"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user