diff --git a/README.md b/README.md index 03d6612..ac548d4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A complete WireGuard management solution combining two powerful components: -1. **WireGuard Dynamic Policy Firewall** - A lightweight, robust iptables/ipset policy engine that restricts and controls WireGuard peer traffic directly from `wg0.conf` using custom `#Access` comments. +1. **WireGuard Dynamic Policy Firewall** - A lightweight, robust nftables policy engine that restricts and controls WireGuard peer traffic directly from `wg0.conf` using custom `#Access` comments. 2. **WGRplane** - A Go-native control plane application with Vue 3 frontend, providing a modern web dashboard for WireGuard management with real-time monitoring, peer CRUD, and hybrid firewall enforcement. --- @@ -31,7 +31,7 @@ A complete WireGuard management solution combining two powerful components: 1. **`wg0.conf`**: The standard WireGuard configuration. Contains standard `[Peer]` configs alongside a custom `#Access` tag. 2. **`wg-sync-policy.sh`**: Safely parses `wg0.conf` and generates a structured `/etc/wireguard/policy.json` atomically. -3. **`wg-policy-engine.sh`**: Reads `policy.json` to generate robust rules, applying `iptables` and `ipset` directly to the system. +3. **`wg-policy-engine.sh`**: Reads `policy.json` to generate robust rules, applying nftables directly to the system. 4. **Watcher Daemon**: Monitors `wg0.conf` for changes via `inotifywait` and triggers the pipeline seamlessly when updates are made. --- @@ -70,9 +70,7 @@ build.bat |---------|----------|---------| | `jq` | **Yes** | `apt install jq` | | `inotify-tools` | **Yes** (for watcher daemon) | `apt install inotify-tools` | -| `ipset` | Optional | `apt install ipset` | - -If `ipset` is not installed, the engine will automatically fall back to per-rule `iptables` whitelist entries. This works fine for small deployments. For large numbers of clients/targets, `ipset` is recommended for O(1) lookup performance. +| `nftables` | **Yes** | `apt install nftables` | --- @@ -129,7 +127,7 @@ WireGuard uses `AllowedIPs` for Cryptokey Routing (deciding which tunnel interfa ## 🛠 `wg-policy-ctl` CLI Usage -You don't need to manually interact with `iptables` or `.json` files. Use the `wg-policy-ctl` wrapper. +You don't need to manually interact with `nft` or `.json` files. Use the `wg-policy-ctl` wrapper. ```bash # View the health of the firewall engine and active locks @@ -138,10 +136,10 @@ wg-policy-ctl status # View the raw, parsed JSON policy wg-policy-ctl policy -# Inspect active iptables rules +# Inspect active nftables rules wg-policy-ctl rules -# Check memory sets mapping IP targets (ipset) +# Check nftables set contents wg-policy-ctl ipset # Manually re-sync rules immediately @@ -278,7 +276,7 @@ Peers without any rules are isolated from other peers and the internet by defaul | **Scheduling** | robfig/cron v3 | | **QR Code** | skip2/go-qrcode | | **Email** | jordan-wright/email (SMTP) | -| **Firewall** | Bash, iptables, ipset, nftables, inotify-tools, jq | +| **Firewall** | Bash, nftables, inotify-tools, jq | | **Container** | Docker (multi-stage build), docker-compose | --- @@ -403,7 +401,7 @@ On first run, it generates a default configuration file at `~/.config/wgrplane/c │ ├── frontend/ # Vue 3 SPA (TypeScript, TailwindCSS) │ └── docs/ # Swagger documentation ├── wg-sync-policy.sh # Parse wg0.conf → policy.json -├── wg-policy-engine.sh # Apply policy.json → iptables/ipset +├── wg-policy-engine.sh # Apply policy.json → nftables ├── wg-sync-watch.sh # inotifywait watcher daemon ├── wg-policy-ctl # CLI wrapper for policy management ├── wg-policy-cleanup.sh # Cleanup script for PostDown diff --git a/build.bat b/build.bat index c0b93b4..a567c12 100644 --- a/build.bat +++ b/build.bat @@ -16,12 +16,78 @@ echo Building %INSTALL_SCRIPT%... >> "%INSTALL_SCRIPT%" echo exit 1 >> "%INSTALL_SCRIPT%" echo fi >> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo if ! command -v wg ^&>/dev/null; then +>> "%INSTALL_SCRIPT%" echo echo "[ERROR] WireGuard is not installed." +>> "%INSTALL_SCRIPT%" echo echo " Debian/Ubuntu: apt install wireguard" +>> "%INSTALL_SCRIPT%" echo echo " RHEL/CentOS: dnf install wireguard-tools" +>> "%INSTALL_SCRIPT%" echo echo " Arch: pacman -S wireguard-tools" +>> "%INSTALL_SCRIPT%" echo exit 1 +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo if [[ ! -f /etc/wireguard/wg0.conf ]]; then +>> "%INSTALL_SCRIPT%" echo echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf" +>> "%INSTALL_SCRIPT%" echo echo " PostUp/PostDown hooks will not be added automatically." +>> "%INSTALL_SCRIPT%" echo echo " Create your wg0.conf first, then re-install." +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo WG_CONF="/etc/wireguard/wg0.conf" +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo install_hooks^(^) { +>> "%INSTALL_SCRIPT%" echo if [[ ! -f "$WG_CONF" ]]; then +>> "%INSTALL_SCRIPT%" echo return 0 +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo if grep -q "wg-sync-policy.sh" "$WG_CONF" 2^>/dev/null; then +>> "%INSTALL_SCRIPT%" echo echo "[OK] PostUp/PostDown hooks already present in $WG_CONF" +>> "%INSTALL_SCRIPT%" echo return 0 +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Adding PostUp/PostDown hooks to $WG_CONF..." +>> "%INSTALL_SCRIPT%" echo cp "$WG_CONF" "${WG_CONF}.bak.$(date +%%Y%%m%%d%%H%%M%%S)" +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo local peer_line +>> "%INSTALL_SCRIPT%" echo peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" ^| head -1 ^| cut -d: -f1) +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo if [[ -n "$peer_line" ]]; then +>> "%INSTALL_SCRIPT%" echo sed -i "${peer_line}i\\ +>> "%INSTALL_SCRIPT%" echo # WireGuard Policy Firewall hooks\\ +>> "%INSTALL_SCRIPT%" echo PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\ +>> "%INSTALL_SCRIPT%" echo PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF" +>> "%INSTALL_SCRIPT%" echo else +>> "%INSTALL_SCRIPT%" echo { +>> "%INSTALL_SCRIPT%" echo echo "" +>> "%INSTALL_SCRIPT%" echo echo "# WireGuard Policy Firewall hooks" +>> "%INSTALL_SCRIPT%" echo echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh" +>> "%INSTALL_SCRIPT%" echo echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh" +>> "%INSTALL_SCRIPT%" echo } ^>^> "$WG_CONF" +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "[OK] Hooks added to $WG_CONF" +>> "%INSTALL_SCRIPT%" echo } +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo remove_hooks^(^) { +>> "%INSTALL_SCRIPT%" echo if [[ ! -f "$WG_CONF" ]]; then +>> "%INSTALL_SCRIPT%" echo return 0 +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2^>/dev/null; then +>> "%INSTALL_SCRIPT%" echo return 0 +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Removing PostUp/PostDown hooks from $WG_CONF..." +>> "%INSTALL_SCRIPT%" echo cp "$WG_CONF" "${WG_CONF}.bak.$(date +%%Y%%m%%d%%H%%M%%S)" +>> "%INSTALL_SCRIPT%" echo sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF" +>> "%INSTALL_SCRIPT%" echo sed -i '/wg-sync-policy\.sh/d' "$WG_CONF" +>> "%INSTALL_SCRIPT%" echo sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF" +>> "%INSTALL_SCRIPT%" echo echo "[OK] Hooks removed from $WG_CONF" +>> "%INSTALL_SCRIPT%" echo } +>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo install_policy^(^) { >> "%INSTALL_SCRIPT%" echo echo "Installing WireGuard Policy Firewall..." >> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo echo "Checking dependencies..." >> "%INSTALL_SCRIPT%" echo apt-get update -y ^|^| true ->> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools ipset iptables ^|^| true +>> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools nftables ^|^| true >> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo echo "Writing scripts to /usr/local/bin/..." >> "%INSTALL_SCRIPT%" echo. @@ -48,7 +114,9 @@ call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.tim >> "%INSTALL_SCRIPT%" echo echo "Enabling and starting services..." >> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy.service >> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy-health.timer ->> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo install_hooks +>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo echo "Installation complete!" >> "%INSTALL_SCRIPT%" echo echo "You can check status with: wg-policy-ctl status" >> "%INSTALL_SCRIPT%" echo } @@ -64,6 +132,8 @@ call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.tim >> "%INSTALL_SCRIPT%" echo /usr/local/bin/wg-policy-cleanup.sh ^|^| true >> "%INSTALL_SCRIPT%" echo fi >> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo remove_hooks +>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo echo "Removing systemd units..." >> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy.service >> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.service diff --git a/build.sh b/build.sh index 106ba17..f5ec925 100644 --- a/build.sh +++ b/build.sh @@ -16,16 +16,82 @@ cat << 'MAIN_EOF' > "$INSTALL_SCRIPT" set -euo pipefail if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root." + echo "This script must be run as root." exit 1 fi +if ! command -v wg &>/dev/null; then + echo "[ERROR] WireGuard is not installed." + echo " Debian/Ubuntu: apt install wireguard" + echo " RHEL/CentOS: dnf install wireguard-tools" + echo " Arch: pacman -S wireguard-tools" + exit 1 +fi + +if [[ ! -f /etc/wireguard/wg0.conf ]]; then + echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf" + echo " PostUp/PostDown hooks will not be added automatically." + echo " Create your wg0.conf first, then re-install." +fi + +WG_CONF="/etc/wireguard/wg0.conf" + +install_hooks() { + if [[ ! -f "$WG_CONF" ]]; then + return 0 + fi + + if grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then + echo "[OK] PostUp/PostDown hooks already present in $WG_CONF" + return 0 + fi + + echo "Adding PostUp/PostDown hooks to $WG_CONF..." + cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)" + + local peer_line + peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" | head -1 | cut -d: -f1) + + if [[ -n "$peer_line" ]]; then + sed -i "${peer_line}i\\ +# WireGuard Policy Firewall hooks\\ +PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\ +PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF" + else + { + echo "" + echo "# WireGuard Policy Firewall hooks" + echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh" + echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh" + } >> "$WG_CONF" + fi + + echo "[OK] Hooks added to $WG_CONF" +} + +remove_hooks() { + if [[ ! -f "$WG_CONF" ]]; then + return 0 + fi + + if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then + return 0 + fi + + echo "Removing PostUp/PostDown hooks from $WG_CONF..." + cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)" + sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF" + sed -i '/wg-sync-policy\.sh/d' "$WG_CONF" + sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF" + echo "[OK] Hooks removed from $WG_CONF" +} + install_policy() { echo "Installing WireGuard Policy Firewall..." echo "Checking dependencies..." apt-get update -y || true - apt-get install -y jq inotify-tools ipset iptables || true + apt-get install -y jq inotify-tools nftables || true echo "Writing scripts to /usr/local/bin/..." @@ -74,7 +140,9 @@ cat << 'MAIN_EOF_END' >> "$INSTALL_SCRIPT" echo "Enabling and starting services..." systemctl enable --now wg-policy.service systemctl enable --now wg-policy-health.timer - + + install_hooks + echo "Installation complete!" echo "You can check status with: wg-policy-ctl status" } @@ -90,6 +158,8 @@ uninstall_policy() { /usr/local/bin/wg-policy-cleanup.sh || true fi + remove_hooks + echo "Removing systemd units..." rm -f /etc/systemd/system/wg-policy.service rm -f /etc/systemd/system/wg-policy-health.service diff --git a/install.sh b/install.sh index beb06bf..0c9a541 100644 --- a/install.sh +++ b/install.sh @@ -1,24 +1,90 @@ #!/bin/bash # WireGuard Policy Firewall Installer/Uninstaller +# This file is auto-generated. Do not edit directly. Run build.sh or build.bat instead. set -euo pipefail if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root." + echo "This script must be run as root." exit 1 fi +if ! command -v wg &>/dev/null; then + echo "[ERROR] WireGuard is not installed." + echo " Debian/Ubuntu: apt install wireguard" + echo " RHEL/CentOS: dnf install wireguard-tools" + echo " Arch: pacman -S wireguard-tools" + exit 1 +fi + +if [[ ! -f /etc/wireguard/wg0.conf ]]; then + echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf" + echo " PostUp/PostDown hooks will not be added automatically." + echo " Create your wg0.conf first, then re-install." +fi + +WG_CONF="/etc/wireguard/wg0.conf" + +install_hooks() { + if [[ ! -f "$WG_CONF" ]]; then + return 0 + fi + + if grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then + echo "[OK] PostUp/PostDown hooks already present in $WG_CONF" + return 0 + fi + + echo "Adding PostUp/PostDown hooks to $WG_CONF..." + cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)" + + local peer_line + peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" | head -1 | cut -d: -f1) + + if [[ -n "$peer_line" ]]; then + sed -i "${peer_line}i\\ +# WireGuard Policy Firewall hooks\\ +PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\ +PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF" + else + { + echo "" + echo "# WireGuard Policy Firewall hooks" + echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh" + echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh" + } >> "$WG_CONF" + fi + + echo "[OK] Hooks added to $WG_CONF" +} + +remove_hooks() { + if [[ ! -f "$WG_CONF" ]]; then + return 0 + fi + + if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then + return 0 + fi + + echo "Removing PostUp/PostDown hooks from $WG_CONF..." + cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)" + sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF" + sed -i '/wg-sync-policy\.sh/d' "$WG_CONF" + sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF" + echo "[OK] Hooks removed from $WG_CONF" +} + install_policy() { echo "Installing WireGuard Policy Firewall..." - # Install dependencies echo "Checking dependencies..." apt-get update -y || true - apt-get install -y jq inotify-tools ipset iptables || true + apt-get install -y jq inotify-tools nftables || true echo "Writing scripts to /usr/local/bin/..." - cat << 'EOF_WG_POLICY_LIB' > /usr/local/bin/wg-policy-lib.sh + cat << 'EOF_WG_POLICY_LIB_SH' > /usr/local/bin/wg-policy-lib.sh #!/bin/bash # wg-policy-lib.sh — Shared functions for WireGuard Policy Firewall # Source this file; do not execute directly. @@ -29,16 +95,16 @@ set -euo pipefail # CONFIGURATION # ============================================================ readonly WG_IF="${WG_IF:-wg0}" -readonly CHAIN="WG_POLICY" -readonly CHAIN_BACKUP="WG_POLICY_BAK" +readonly NFT_TABLE="wg_policy" +readonly NFT_TABLE_FULL="inet wg_policy" +readonly NFT_SET_V4="wg_allowed_v4" +readonly NFT_SET_V6="wg_allowed_v6" readonly POLICY_FILE="/etc/wireguard/policy.json" readonly WG_CONF="/etc/wireguard/wg0.conf" readonly LOCK_FILE="/var/lock/wg-policy.lock" readonly BACKUP_DIR="/etc/wireguard/backups" readonly LOG_PREFIX="WG_DROP" -readonly LOG_RATE="10/min" -readonly IPSET_V4="wg_allowed_v4" -readonly IPSET_V6="wg_allowed_v6" +readonly LOG_RATE="10/minute" readonly MAX_RETRY=3 readonly RETRY_DELAY=2 readonly DEBOUNCE_SEC=2 @@ -54,17 +120,14 @@ log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; } # VALIDATION # ============================================================ -# Validate IPv4 address (strict: 0-255 per octet, no leading zeros) validate_ipv4() { local ip="$1" - # Match basic pattern if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then return 1 fi local IFS='.' read -ra octets <<< "$ip" for octet in "${octets[@]}"; do - # Reject leading zeros (except "0" itself) if [[ "$octet" =~ ^0[0-9] ]]; then return 1 fi @@ -75,7 +138,6 @@ validate_ipv4() { return 0 } -# Validate IPv4 CIDR (e.g., 192.168.1.0/24) validate_ipv4_cidr() { local cidr="$1" local ip prefix @@ -84,7 +146,6 @@ validate_ipv4_cidr() { ip="${cidr%%/*}" prefix="${cidr##*/}" else - # Single IP treated as /32 ip="$cidr" prefix="32" fi @@ -99,10 +160,8 @@ validate_ipv4_cidr() { return 0 } -# Validate IPv6 address (basic check) validate_ipv6() { local ip="$1" - # Basic IPv6 pattern — covers full, compressed, and mixed notation if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || \ [[ "$ip" =~ ^::([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$ ]] || \ [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){1,7}:$ ]] || \ @@ -113,7 +172,6 @@ validate_ipv6() { return 1 } -# Validate IPv6 CIDR validate_ipv6_cidr() { local cidr="$1" local ip prefix @@ -136,7 +194,6 @@ validate_ipv6_cidr() { return 0 } -# Generic CIDR validator — dispatches to v4 or v6 validate_cidr() { local cidr="$1" if [[ "$cidr" == *":"* ]]; then @@ -147,36 +204,11 @@ validate_cidr() { } # ============================================================ -# IPSET MANAGEMENT +# NFTABLES HELPERS # ============================================================ -has_ipset() { - command -v ipset &>/dev/null -} - -ensure_ipset() { - local name="$1" family="$2" - has_ipset || return 0 - if ! ipset list "$name" &>/dev/null; then - ipset create "$name" hash:net,net family "$family" hashsize 1024 maxelem 65536 timeout 0 - log_info "Created ipset: $name (family=$family)" - fi -} - -flush_ipset() { - local name="$1" - has_ipset || return 0 - if ipset list "$name" &>/dev/null; then - ipset flush "$name" - fi -} - -destroy_ipset() { - local name="$1" - has_ipset || return 0 - if ipset list "$name" &>/dev/null; then - ipset destroy "$name" - fi +has_nft() { + command -v nft &>/dev/null } # ============================================================ @@ -197,7 +229,7 @@ retry() { log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..." sleep "$delay" (( attempt++ )) - (( delay *= 2 )) # exponential backoff + (( delay *= 2 )) done log_error "All $max_attempts attempts failed for: $*" @@ -219,7 +251,6 @@ acquire_lock() { } release_lock() { - # Lock released automatically when fd closes, but we clean up file rm -f "$LOCK_FILE" 2>/dev/null || true } @@ -237,7 +268,6 @@ backup_policy() { log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json" fi - # Keep only last 50 backups local count count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l) if (( count > 50 )); then @@ -250,31 +280,24 @@ backup_policy() { fi } -backup_iptables() { +backup_nftables() { mkdir -p "$BACKUP_DIR" local timestamp timestamp="$(date '+%Y%m%d_%H%M%S')" - if iptables-save > "${BACKUP_DIR}/iptables_${timestamp}.rules" 2>/dev/null; then - log_info "iptables backup: ${BACKUP_DIR}/iptables_${timestamp}.rules" + if nft list ruleset > "${BACKUP_DIR}/nftables_${timestamp}.rules" 2>/dev/null; then + log_info "nftables backup: ${BACKUP_DIR}/nftables_${timestamp}.rules" fi - if command -v ip6tables-save &>/dev/null; then - ip6tables-save > "${BACKUP_DIR}/ip6tables_${timestamp}.rules" 2>/dev/null || true + local count + count=$(find "$BACKUP_DIR" -name 'nftables_*.rules' -type f | wc -l) + if (( count > 20 )); then + find "$BACKUP_DIR" -name 'nftables_*.rules' -type f -printf '%T@ %p\n' \ + | sort -n \ + | head -n $(( count - 20 )) \ + | awk '{print $2}' \ + | xargs rm -f fi - - # Keep only last 20 iptables backups - for prefix in iptables ip6tables; do - local count - count=$(find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f | wc -l) - if (( count > 20 )); then - find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f -printf '%T@ %p\n' \ - | sort -n \ - | head -n $(( count - 20 )) \ - | awk '{print $2}' \ - | xargs rm -f - fi - done } # ============================================================ @@ -335,32 +358,32 @@ health_check() { status=1 fi - # 3. Check chain exists - if iptables -L "$CHAIN" -n &>/dev/null; then + # 3. Check nftables table exists + if nft list table "$NFT_TABLE_FULL" &>/dev/null; then local rule_count - rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l) - report+="[OK] Chain $CHAIN active ($rule_count rules)\n" + rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0) + report+="[OK] Table $NFT_TABLE active ($rule_count rules)\n" else - report+="[WARN] Chain $CHAIN not found\n" + report+="[WARN] Table $NFT_TABLE not found\n" status=1 fi - # 4. Check FORWARD reference - if iptables -L FORWARD -n 2>/dev/null | grep -q "$CHAIN"; then - report+="[OK] FORWARD chain references $CHAIN\n" + # 4. Check forward chain exists in table + if nft list chain "$NFT_TABLE_FULL" forward &>/dev/null; then + report+="[OK] Forward chain exists in $NFT_TABLE\n" else - report+="[WARN] FORWARD chain has no reference to $CHAIN\n" + report+="[WARN] Forward chain not found in $NFT_TABLE\n" status=1 fi - # 5. Check ipset - for set_name in "$IPSET_V4" "$IPSET_V6"; do - if ipset list "$set_name" &>/dev/null; then + # 5. Check nftables sets + for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do + if nft list set "$NFT_TABLE_FULL" "$set_name" &>/dev/null; then local entry_count - entry_count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) - report+="[OK] ipset $set_name active ($entry_count entries)\n" + entry_count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c '^\s*' || echo 0) + report+="[OK] Set $set_name active ($entry_count entries)\n" else - report+="[INFO] ipset $set_name not created (may not be needed)\n" + report+="[INFO] Set $set_name not created (may not be needed)\n" fi done @@ -387,61 +410,159 @@ health_check() { echo -e "$report" return $status } -EOF_WG_POLICY_LIB - cat << 'EOF_WG_POLICY_ENGINE' > /usr/local/bin/wg-policy-engine.sh +EOF_WG_POLICY_LIB_SH + + cat << 'EOF_WG_POLICY_ENGINE_SH' > /usr/local/bin/wg-policy-engine.sh #!/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 } # ============================================================ @@ -467,8 +588,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 @@ -497,227 +618,76 @@ 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 "$@" -EOF_WG_POLICY_ENGINE - cat << 'EOF_WG_POLICY_CLEANUP' > /usr/local/bin/wg-policy-cleanup.sh +EOF_WG_POLICY_ENGINE_SH + + cat << 'EOF_WG_POLICY_CLEANUP_SH' > /usr/local/bin/wg-policy-cleanup.sh #!/bin/bash # wg-policy-cleanup.sh — Clean removal of all policy artifacts @@ -729,72 +699,19 @@ source "${SCRIPT_DIR}/wg-policy-lib.sh" main() { log_info "Starting cleanup..." - local DEF_IF - DEF_IF="$(detect_default_if)" + nft delete table inet wg_policy 2>/dev/null || true + log_info "Removed nftables table inet wg_policy" - # === Base Routing Cleanup === - while iptables -D FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; do :; done - while iptables -t nat -D POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; do :; done - log_info "Removed base routing and NAT rules" - - # === IPv4 chain cleanup === - local removed=0 - - while true; do - local line="" - line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) - if [[ -n "$line" ]]; then - iptables -D FORWARD "$line" 2>/dev/null || break - (( removed++ )) - else - break - fi - done - - if (( removed > 0 )); then - log_info "Removed $removed FORWARD references" - fi - - iptables -F "$CHAIN" 2>/dev/null || true - iptables -X "$CHAIN" 2>/dev/null || true - - # Cleanup backup chain too - iptables -F "$CHAIN_BACKUP" 2>/dev/null || true - iptables -X "$CHAIN_BACKUP" 2>/dev/null || true - - # === IPv6 chain cleanup === - if command -v ip6tables &>/dev/null; then - while true; do - local line6="" - line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) - if [[ -n "$line6" ]]; then - ip6tables -D FORWARD "$line6" 2>/dev/null || break - else - break - fi - done - ip6tables -F "$CHAIN" 2>/dev/null || true - ip6tables -X "$CHAIN" 2>/dev/null || true - ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true - ip6tables -X "$CHAIN_BACKUP" 2>/dev/null || true - fi - - # === ipset cleanup === - destroy_ipset "$IPSET_V4" 2>/dev/null || true - destroy_ipset "$IPSET_V6" 2>/dev/null || true - destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true - destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true - - # === Lock cleanup === rm -f "$LOCK_FILE" 2>/dev/null || true log_info "Cleanup complete" } main "$@" -EOF_WG_POLICY_CLEANUP - cat << 'EOF_WG_SYNC_POLICY' > /usr/local/bin/wg-sync-policy.sh +EOF_WG_POLICY_CLEANUP_SH + + cat << 'EOF_WG_SYNC_POLICY_SH' > /usr/local/bin/wg-sync-policy.sh #!/bin/bash # wg-sync-policy.sh — Reads wg0.conf, validates, generates policy.json atomically # Fixed: IP validation, atomic write, proper locking, error handling @@ -932,9 +849,10 @@ main() { } main "$@" -EOF_WG_SYNC_POLICY - cat << 'EOF_WG_SYNC_WATCH' > /usr/local/bin/wg-sync-watch.sh +EOF_WG_SYNC_POLICY_SH + + cat << 'EOF_WG_SYNC_WATCH_SH' > /usr/local/bin/wg-sync-watch.sh #!/bin/bash # wg-sync-watch.sh — Watches wg0.conf for changes with debounce # Fixed: proper debounce, error isolation, health reporting @@ -1005,7 +923,8 @@ while read -r events filename; do fi done -EOF_WG_SYNC_WATCH + +EOF_WG_SYNC_WATCH_SH cat << 'EOF_WG_POLICY_CTL' > /usr/local/bin/wg-policy-ctl #!/bin/bash @@ -1023,11 +942,11 @@ Usage: $(basename "$0") Commands: status Show full health check report policy Display current policy.json formatted - rules Show current iptables rules in WG_POLICY chain - ipset Show ipset contents + rules Show current nftables rules + ipset Show nftables set contents log Tail WG_DROP logs (last 50 lines) reload Force re-sync and re-apply policy - backup Manual backup of policy + iptables + backup Manual backup of policy + nftables stats Show connection and rule statistics validate Validate policy.json without applying help Show this help @@ -1053,27 +972,18 @@ cmd_policy() { } cmd_rules() { - echo "=== IPv4 Chain: $CHAIN ===" - if iptables -L "$CHAIN" -n -v --line-numbers 2>/dev/null; then + echo "=== Table: $NFT_TABLE_FULL ===" + if nft list table "$NFT_TABLE_FULL" 2>/dev/null; then echo "" else - echo "(chain not found)" - fi - - echo "=== FORWARD references ===" - iptables -L FORWARD -n -v --line-numbers 2>/dev/null | grep -i "$CHAIN" || echo "(none)" - - if command -v ip6tables &>/dev/null; then - echo "" - echo "=== IPv6 Chain: $CHAIN ===" - ip6tables -L "$CHAIN" -n -v --line-numbers 2>/dev/null || echo "(chain not found)" + echo "(table not found)" fi } cmd_ipset() { - for set_name in "$IPSET_V4" "$IPSET_V6"; do - echo "=== ipset: $set_name ===" - if ipset list "$set_name" 2>/dev/null; then + for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do + echo "=== nft set: $set_name ===" + if nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null; then echo "" else echo "(not found)" @@ -1104,7 +1014,7 @@ cmd_reload() { cmd_backup() { backup_policy - backup_iptables + backup_nftables log_info "Manual backup complete. Files in: $BACKUP_DIR" } @@ -1121,18 +1031,14 @@ cmd_stats() { jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A" echo "" - echo "=== Active iptables rules ===" - iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l || echo "N/A" + echo "=== Active nftables rules ===" + nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '#' || echo "N/A" echo "" - echo "=== Drop count (since boot) ===" - iptables -L "$CHAIN" -n -v 2>/dev/null | grep "DROP" | awk '{sum += $1} END {print sum+0, "packets dropped"}' - - echo "" - echo "=== ipset entries ===" - for set_name in "$IPSET_V4" "$IPSET_V6"; do + echo "=== nft set entries ===" + for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do local count - count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) + count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c -E '^\s+[0-9a-f]' || echo 0) echo " $set_name: $count entries" done } @@ -1194,9 +1100,9 @@ case "${1:-help}" in validate) cmd_validate ;; help|*) usage ;; esac + EOF_WG_POLICY_CTL - # Write systemd files echo "Writing systemd units to /etc/systemd/system/..." cat << 'EOF_WG_POLICY_SERVICE' > /etc/systemd/system/wg-policy.service @@ -1228,6 +1134,7 @@ PrivateTmp=yes [Install] WantedBy=multi-user.target + EOF_WG_POLICY_SERVICE cat << 'EOF_WG_POLICY_HEALTH_SERVICE' > /etc/systemd/system/wg-policy-health.service @@ -1240,6 +1147,7 @@ ExecStart=/bin/bash -c 'source /usr/local/bin/wg-policy-lib.sh && health_check' StandardOutput=journal StandardError=journal SyslogIdentifier=wg-policy-health + EOF_WG_POLICY_HEALTH_SERVICE cat << 'EOF_WG_POLICY_HEALTH_TIMER' > /etc/systemd/system/wg-policy-health.timer @@ -1253,19 +1161,20 @@ AccuracySec=30 [Install] WantedBy=timers.target + EOF_WG_POLICY_HEALTH_TIMER - # Make executable chmod +x /usr/local/bin/wg-*.sh /usr/local/bin/wg-policy-ctl - # Start services echo "Reloading systemd daemon..." systemctl daemon-reload echo "Enabling and starting services..." systemctl enable --now wg-policy.service systemctl enable --now wg-policy-health.timer - + + install_hooks + echo "Installation complete!" echo "You can check status with: wg-policy-ctl status" } @@ -1281,6 +1190,8 @@ uninstall_policy() { /usr/local/bin/wg-policy-cleanup.sh || true fi + remove_hooks + echo "Removing systemd units..." rm -f /etc/systemd/system/wg-policy.service rm -f /etc/systemd/system/wg-policy-health.service diff --git a/wg-policy-cleanup.sh b/wg-policy-cleanup.sh index be1cd35..8f94923 100644 --- a/wg-policy-cleanup.sh +++ b/wg-policy-cleanup.sh @@ -9,63 +9,9 @@ source "${SCRIPT_DIR}/wg-policy-lib.sh" main() { log_info "Starting cleanup..." - local DEF_IF - DEF_IF="$(detect_default_if)" + nft delete table inet wg_policy 2>/dev/null || true + log_info "Removed nftables table inet wg_policy" - # === Base Routing Cleanup === - while iptables -D FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; do :; done - while iptables -t nat -D POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; do :; done - log_info "Removed base routing and NAT rules" - - # === IPv4 chain cleanup === - local removed=0 - - while true; do - local line="" - line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) - if [[ -n "$line" ]]; then - iptables -D FORWARD "$line" 2>/dev/null || break - (( removed++ )) - else - break - fi - done - - if (( removed > 0 )); then - log_info "Removed $removed FORWARD references" - fi - - iptables -F "$CHAIN" 2>/dev/null || true - iptables -X "$CHAIN" 2>/dev/null || true - - # Cleanup backup chain too - iptables -F "$CHAIN_BACKUP" 2>/dev/null || true - iptables -X "$CHAIN_BACKUP" 2>/dev/null || true - - # === IPv6 chain cleanup === - if command -v ip6tables &>/dev/null; then - while true; do - local line6="" - line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) - if [[ -n "$line6" ]]; then - ip6tables -D FORWARD "$line6" 2>/dev/null || break - else - break - fi - done - ip6tables -F "$CHAIN" 2>/dev/null || true - ip6tables -X "$CHAIN" 2>/dev/null || true - ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true - ip6tables -X "$CHAIN_BACKUP" 2>/dev/null || true - fi - - # === ipset cleanup === - destroy_ipset "$IPSET_V4" 2>/dev/null || true - destroy_ipset "$IPSET_V6" 2>/dev/null || true - destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true - destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true - - # === Lock cleanup === rm -f "$LOCK_FILE" 2>/dev/null || true log_info "Cleanup complete" diff --git a/wg-policy-ctl b/wg-policy-ctl index 8637744..a684908 100644 --- a/wg-policy-ctl +++ b/wg-policy-ctl @@ -13,11 +13,11 @@ Usage: $(basename "$0") Commands: status Show full health check report policy Display current policy.json formatted - rules Show current iptables rules in WG_POLICY chain - ipset Show ipset contents + rules Show current nftables rules + ipset Show nftables set contents log Tail WG_DROP logs (last 50 lines) reload Force re-sync and re-apply policy - backup Manual backup of policy + iptables + backup Manual backup of policy + nftables stats Show connection and rule statistics validate Validate policy.json without applying help Show this help @@ -43,27 +43,18 @@ cmd_policy() { } cmd_rules() { - echo "=== IPv4 Chain: $CHAIN ===" - if iptables -L "$CHAIN" -n -v --line-numbers 2>/dev/null; then + echo "=== Table: $NFT_TABLE_FULL ===" + if nft list table "$NFT_TABLE_FULL" 2>/dev/null; then echo "" else - echo "(chain not found)" - fi - - echo "=== FORWARD references ===" - iptables -L FORWARD -n -v --line-numbers 2>/dev/null | grep -i "$CHAIN" || echo "(none)" - - if command -v ip6tables &>/dev/null; then - echo "" - echo "=== IPv6 Chain: $CHAIN ===" - ip6tables -L "$CHAIN" -n -v --line-numbers 2>/dev/null || echo "(chain not found)" + echo "(table not found)" fi } cmd_ipset() { - for set_name in "$IPSET_V4" "$IPSET_V6"; do - echo "=== ipset: $set_name ===" - if ipset list "$set_name" 2>/dev/null; then + for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do + echo "=== nft set: $set_name ===" + if nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null; then echo "" else echo "(not found)" @@ -94,7 +85,7 @@ cmd_reload() { cmd_backup() { backup_policy - backup_iptables + backup_nftables log_info "Manual backup complete. Files in: $BACKUP_DIR" } @@ -111,18 +102,14 @@ cmd_stats() { jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A" echo "" - echo "=== Active iptables rules ===" - iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l || echo "N/A" + echo "=== Active nftables rules ===" + nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '#' || echo "N/A" echo "" - echo "=== Drop count (since boot) ===" - iptables -L "$CHAIN" -n -v 2>/dev/null | grep "DROP" | awk '{sum += $1} END {print sum+0, "packets dropped"}' - - echo "" - echo "=== ipset entries ===" - for set_name in "$IPSET_V4" "$IPSET_V6"; do + echo "=== nft set entries ===" + for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do local count - count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) + count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c -E '^\s+[0-9a-f]' || echo 0) echo " $set_name: $count entries" done } @@ -183,4 +170,4 @@ case "${1:-help}" in stats) cmd_stats ;; validate) cmd_validate ;; help|*) usage ;; -esac \ No newline at end of file +esac diff --git a/wg-policy-engine.sh b/wg-policy-engine.sh index d561b8d..dfa0cf8 100644 --- a/wg-policy-engine.sh +++ b/wg-policy-engine.sh @@ -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 "$@" diff --git a/wg-policy-lib.sh b/wg-policy-lib.sh index 5efa809..112fd4d 100644 --- a/wg-policy-lib.sh +++ b/wg-policy-lib.sh @@ -8,16 +8,16 @@ set -euo pipefail # CONFIGURATION # ============================================================ readonly WG_IF="${WG_IF:-wg0}" -readonly CHAIN="WG_POLICY" -readonly CHAIN_BACKUP="WG_POLICY_BAK" +readonly NFT_TABLE="wg_policy" +readonly NFT_TABLE_FULL="inet wg_policy" +readonly NFT_SET_V4="wg_allowed_v4" +readonly NFT_SET_V6="wg_allowed_v6" readonly POLICY_FILE="/etc/wireguard/policy.json" readonly WG_CONF="/etc/wireguard/wg0.conf" readonly LOCK_FILE="/var/lock/wg-policy.lock" readonly BACKUP_DIR="/etc/wireguard/backups" readonly LOG_PREFIX="WG_DROP" -readonly LOG_RATE="10/min" -readonly IPSET_V4="wg_allowed_v4" -readonly IPSET_V6="wg_allowed_v6" +readonly LOG_RATE="10/minute" readonly MAX_RETRY=3 readonly RETRY_DELAY=2 readonly DEBOUNCE_SEC=2 @@ -33,17 +33,14 @@ log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; } # VALIDATION # ============================================================ -# Validate IPv4 address (strict: 0-255 per octet, no leading zeros) validate_ipv4() { local ip="$1" - # Match basic pattern if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then return 1 fi local IFS='.' read -ra octets <<< "$ip" for octet in "${octets[@]}"; do - # Reject leading zeros (except "0" itself) if [[ "$octet" =~ ^0[0-9] ]]; then return 1 fi @@ -54,7 +51,6 @@ validate_ipv4() { return 0 } -# Validate IPv4 CIDR (e.g., 192.168.1.0/24) validate_ipv4_cidr() { local cidr="$1" local ip prefix @@ -63,7 +59,6 @@ validate_ipv4_cidr() { ip="${cidr%%/*}" prefix="${cidr##*/}" else - # Single IP treated as /32 ip="$cidr" prefix="32" fi @@ -78,10 +73,8 @@ validate_ipv4_cidr() { return 0 } -# Validate IPv6 address (basic check) validate_ipv6() { local ip="$1" - # Basic IPv6 pattern — covers full, compressed, and mixed notation if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || \ [[ "$ip" =~ ^::([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$ ]] || \ [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){1,7}:$ ]] || \ @@ -92,7 +85,6 @@ validate_ipv6() { return 1 } -# Validate IPv6 CIDR validate_ipv6_cidr() { local cidr="$1" local ip prefix @@ -115,7 +107,6 @@ validate_ipv6_cidr() { return 0 } -# Generic CIDR validator — dispatches to v4 or v6 validate_cidr() { local cidr="$1" if [[ "$cidr" == *":"* ]]; then @@ -126,36 +117,11 @@ validate_cidr() { } # ============================================================ -# IPSET MANAGEMENT +# NFTABLES HELPERS # ============================================================ -has_ipset() { - command -v ipset &>/dev/null -} - -ensure_ipset() { - local name="$1" family="$2" - has_ipset || return 0 - if ! ipset list "$name" &>/dev/null; then - ipset create "$name" hash:net,net family "$family" hashsize 1024 maxelem 65536 timeout 0 - log_info "Created ipset: $name (family=$family)" - fi -} - -flush_ipset() { - local name="$1" - has_ipset || return 0 - if ipset list "$name" &>/dev/null; then - ipset flush "$name" - fi -} - -destroy_ipset() { - local name="$1" - has_ipset || return 0 - if ipset list "$name" &>/dev/null; then - ipset destroy "$name" - fi +has_nft() { + command -v nft &>/dev/null } # ============================================================ @@ -176,7 +142,7 @@ retry() { log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..." sleep "$delay" (( attempt++ )) - (( delay *= 2 )) # exponential backoff + (( delay *= 2 )) done log_error "All $max_attempts attempts failed for: $*" @@ -198,7 +164,6 @@ acquire_lock() { } release_lock() { - # Lock released automatically when fd closes, but we clean up file rm -f "$LOCK_FILE" 2>/dev/null || true } @@ -216,7 +181,6 @@ backup_policy() { log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json" fi - # Keep only last 50 backups local count count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l) if (( count > 50 )); then @@ -229,31 +193,24 @@ backup_policy() { fi } -backup_iptables() { +backup_nftables() { mkdir -p "$BACKUP_DIR" local timestamp timestamp="$(date '+%Y%m%d_%H%M%S')" - if iptables-save > "${BACKUP_DIR}/iptables_${timestamp}.rules" 2>/dev/null; then - log_info "iptables backup: ${BACKUP_DIR}/iptables_${timestamp}.rules" + if nft list ruleset > "${BACKUP_DIR}/nftables_${timestamp}.rules" 2>/dev/null; then + log_info "nftables backup: ${BACKUP_DIR}/nftables_${timestamp}.rules" fi - if command -v ip6tables-save &>/dev/null; then - ip6tables-save > "${BACKUP_DIR}/ip6tables_${timestamp}.rules" 2>/dev/null || true + local count + count=$(find "$BACKUP_DIR" -name 'nftables_*.rules' -type f | wc -l) + if (( count > 20 )); then + find "$BACKUP_DIR" -name 'nftables_*.rules' -type f -printf '%T@ %p\n' \ + | sort -n \ + | head -n $(( count - 20 )) \ + | awk '{print $2}' \ + | xargs rm -f fi - - # Keep only last 20 iptables backups - for prefix in iptables ip6tables; do - local count - count=$(find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f | wc -l) - if (( count > 20 )); then - find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f -printf '%T@ %p\n' \ - | sort -n \ - | head -n $(( count - 20 )) \ - | awk '{print $2}' \ - | xargs rm -f - fi - done } # ============================================================ @@ -314,32 +271,32 @@ health_check() { status=1 fi - # 3. Check chain exists - if iptables -L "$CHAIN" -n &>/dev/null; then + # 3. Check nftables table exists + if nft list table "$NFT_TABLE_FULL" &>/dev/null; then local rule_count - rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l) - report+="[OK] Chain $CHAIN active ($rule_count rules)\n" + rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0) + report+="[OK] Table $NFT_TABLE active ($rule_count rules)\n" else - report+="[WARN] Chain $CHAIN not found\n" + report+="[WARN] Table $NFT_TABLE not found\n" status=1 fi - # 4. Check FORWARD reference - if iptables -L FORWARD -n 2>/dev/null | grep -q "$CHAIN"; then - report+="[OK] FORWARD chain references $CHAIN\n" + # 4. Check forward chain exists in table + if nft list chain "$NFT_TABLE_FULL" forward &>/dev/null; then + report+="[OK] Forward chain exists in $NFT_TABLE\n" else - report+="[WARN] FORWARD chain has no reference to $CHAIN\n" + report+="[WARN] Forward chain not found in $NFT_TABLE\n" status=1 fi - # 5. Check ipset - for set_name in "$IPSET_V4" "$IPSET_V6"; do - if ipset list "$set_name" &>/dev/null; then + # 5. Check nftables sets + for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do + if nft list set "$NFT_TABLE_FULL" "$set_name" &>/dev/null; then local entry_count - entry_count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) - report+="[OK] ipset $set_name active ($entry_count entries)\n" + entry_count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c '^\s*' || echo 0) + report+="[OK] Set $set_name active ($entry_count entries)\n" else - report+="[INFO] ipset $set_name not created (may not be needed)\n" + report+="[INFO] Set $set_name not created (may not be needed)\n" fi done