#!/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." 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 nftables || true echo "Writing scripts to /usr/local/bin/..." 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. set -euo pipefail # ============================================================ # CONFIGURATION # ============================================================ readonly WG_IF="${WG_IF:-wg0}" 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/minute" readonly MAX_RETRY=3 readonly RETRY_DELAY=2 readonly DEBOUNCE_SEC=2 # ============================================================ # LOGGING # ============================================================ log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*"; } log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" >&2; } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; } # ============================================================ # VALIDATION # ============================================================ validate_ipv4() { local ip="$1" 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 if [[ "$octet" =~ ^0[0-9] ]]; then return 1 fi if (( octet < 0 || octet > 255 )); then return 1 fi done return 0 } validate_ipv4_cidr() { local cidr="$1" local ip prefix if [[ "$cidr" == *"/"* ]]; then ip="${cidr%%/*}" prefix="${cidr##*/}" else ip="$cidr" prefix="32" fi if ! validate_ipv4 "$ip"; then return 1 fi if [[ ! "$prefix" =~ ^[0-9]+$ ]] || (( prefix < 0 || prefix > 32 )); then return 1 fi return 0 } validate_ipv6() { local ip="$1" 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}:$ ]] || \ [[ "$ip" == "::" ]] || \ [[ "$ip" == "::1" ]]; then return 0 fi return 1 } validate_ipv6_cidr() { local cidr="$1" local ip prefix if [[ "$cidr" == *"/"* ]]; then ip="${cidr%%/*}" prefix="${cidr##*/}" else ip="$cidr" prefix="128" fi if ! validate_ipv6 "$ip"; then return 1 fi if [[ ! "$prefix" =~ ^[0-9]+$ ]] || (( prefix < 0 || prefix > 128 )); then return 1 fi return 0 } validate_cidr() { local cidr="$1" if [[ "$cidr" == *":"* ]]; then validate_ipv6_cidr "$cidr" else validate_ipv4_cidr "$cidr" fi } # ============================================================ # NFTABLES HELPERS # ============================================================ has_nft() { command -v nft &>/dev/null } # ============================================================ # RETRY MECHANISM # ============================================================ retry() { local max_attempts="${MAX_RETRY}" local delay="${RETRY_DELAY}" local attempt=1 local exit_code=0 while (( attempt <= max_attempts )); do if "$@"; then return 0 fi exit_code=$? log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..." sleep "$delay" (( attempt++ )) (( delay *= 2 )) done log_error "All $max_attempts attempts failed for: $*" return "$exit_code" } # ============================================================ # LOCK MANAGEMENT # ============================================================ acquire_lock() { local lock_fd=200 eval "exec ${lock_fd}>\"${LOCK_FILE}\"" if ! flock -x -w 10 "$lock_fd"; then log_error "Failed to acquire lock: ${LOCK_FILE} (timeout 10s)" return 1 fi log_info "Lock acquired: ${LOCK_FILE}" } release_lock() { rm -f "$LOCK_FILE" 2>/dev/null || true } # ============================================================ # BACKUP # ============================================================ backup_policy() { mkdir -p "$BACKUP_DIR" local timestamp timestamp="$(date '+%Y%m%d_%H%M%S')" if [[ -f "$POLICY_FILE" ]]; then cp "$POLICY_FILE" "${BACKUP_DIR}/policy_${timestamp}.json" log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json" fi local count count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l) if (( count > 50 )); then find "$BACKUP_DIR" -name 'policy_*.json' -type f -printf '%T@ %p\n' \ | sort -n \ | head -n $(( count - 50 )) \ | awk '{print $2}' \ | xargs rm -f log_info "Pruned old backups (kept 50)" fi } backup_nftables() { mkdir -p "$BACKUP_DIR" local timestamp timestamp="$(date '+%Y%m%d_%H%M%S')" if nft list ruleset > "${BACKUP_DIR}/nftables_${timestamp}.rules" 2>/dev/null; then log_info "nftables backup: ${BACKUP_DIR}/nftables_${timestamp}.rules" fi 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 } # ============================================================ # DETECTION HELPERS # ============================================================ detect_lan_subnets() { ip -o -4 route show scope link \ | awk '{print $1}' \ | grep -E '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' \ | sort -u } detect_wg_subnet() { local family="${1:-inet}" if [[ "$family" == "inet6" ]]; then ip -o -6 addr show "$WG_IF" 2>/dev/null \ | awk '{print $4; exit}' else ip -o -4 addr show "$WG_IF" 2>/dev/null \ | awk '{print $4; exit}' fi } detect_default_if() { local def_if def_if=$(ip -4 route ls 2>/dev/null | grep default | grep -Po '(?<=dev )(\S+)' | head -1 || true) if [[ -z "$def_if" ]]; then echo "eth0" else echo "$def_if" fi } # ============================================================ # HEALTH CHECK # ============================================================ health_check() { local status=0 local report="" # 1. Check interface exists if ip link show "$WG_IF" &>/dev/null; then report+="[OK] Interface $WG_IF is UP\n" else report+="[FAIL] Interface $WG_IF not found\n" status=1 fi # 2. Check policy.json exists and is valid if [[ -f "$POLICY_FILE" ]] && jq empty "$POLICY_FILE" 2>/dev/null; then local client_count client_count=$(jq '(.clients // {}) | length' "$POLICY_FILE") report+="[OK] policy.json valid ($client_count clients)\n" else report+="[FAIL] policy.json missing or corrupt\n" status=1 fi # 3. Check nftables table exists if nft list table "$NFT_TABLE_FULL" &>/dev/null; then local rule_count 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] Table $NFT_TABLE not found\n" status=1 fi # 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 not found in $NFT_TABLE\n" status=1 fi # 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=$(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] Set $set_name not created (may not be needed)\n" fi done # 6. Check watcher service if systemctl is-active --quiet wg-policy.service 2>/dev/null; then report+="[OK] wg-policy.service is running\n" else report+="[INFO] wg-policy.service not running\n" fi # 7. Check lock file not stale if [[ -f "$LOCK_FILE" ]]; then local lock_age lock_age=$(( $(date +%s) - $(stat -c %Y "$LOCK_FILE" 2>/dev/null || echo 0) )) if (( lock_age > 300 )); then report+="[WARN] Stale lock file (${lock_age}s old)\n" else report+="[OK] Lock file age: ${lock_age}s\n" fi else report+="[OK] No stale lock file\n" fi echo -e "$report" return $status } 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 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 ruleset..." trap - ERR 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 log_error "Rollback: failed to restore from backup" fi fi } # ============================================================ # RULESET GENERATION # ============================================================ generate_ruleset() { local WG_SUBNET="$1" local WG_SUBNET_V6="$2" local LAN_SUBNETS="$3" local DEF_IF="$4" 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 # Client isolation (IPv6) if [[ -n "$WG_SUBNET_V6" ]]; then cat >> "$NFT_FILE" << EOF /* 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 } # ============================================================ # MAIN # ============================================================ main() { log_info "Starting policy engine..." # === VALIDATE === if ! ip link show "$WG_IF" &>/dev/null; then log_error "Interface $WG_IF is not running. Aborting policy engine." exit 1 fi if [[ ! -f "$POLICY_FILE" ]]; then log_error "Policy file not found: $POLICY_FILE" exit 1 fi if ! jq empty "$POLICY_FILE" 2>/dev/null; then log_error "policy.json is corrupt" exit 1 fi # Backup current nftables state backup_nftables # Set trap for rollback on failure trap 'rollback' ERR # === DETECT SUBNETS === local WG_SUBNET WG_SUBNET_V6 LAN_SUBNETS DEF_IF WG_SUBNET="$(detect_wg_subnet inet)" WG_SUBNET_V6="$(detect_wg_subnet inet6)" LAN_SUBNETS="$(detect_lan_subnets)" DEF_IF="$(detect_default_if)" if [[ -z "$WG_SUBNET" ]]; then log_warn "Interface $WG_IF has no IPv4, skipping client isolation" else log_info "WG IPv4 subnet: $WG_SUBNET" fi if [[ -n "$WG_SUBNET_V6" ]]; then log_info "WG IPv6 subnet: $WG_SUBNET_V6" fi if [[ -n "$LAN_SUBNETS" ]]; then log_info "Detected LAN subnets:" echo "$LAN_SUBNETS" | while read -r s; do log_info " $s"; done fi # === BASE ROUTING & NAT === sysctl -w net.ipv4.ip_forward=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 # === DELETE OLD TABLE === nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true # === 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 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 < <(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) log_info "Set $NFT_SET_V4: $v4_count entries, $NFT_SET_V6: $v6_count entries" # === 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=$(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] nftables policy applied. Table: $NFT_TABLE" } main "$@" 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 set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" main() { log_info "Starting cleanup..." nft delete table inet wg_policy 2>/dev/null || true log_info "Removed nftables table inet wg_policy" rm -f "$LOCK_FILE" 2>/dev/null || true log_info "Cleanup complete" } main "$@" 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 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" # ============================================================ # MAIN # ============================================================ main() { log_info "Starting policy sync..." # Validate prerequisites if [[ ! -f "$WG_CONF" ]]; then log_error "WireGuard config not found: $WG_CONF" exit 1 fi if ! command -v jq &>/dev/null; then log_error "jq is required but not installed" exit 1 fi # Acquire lock acquire_lock trap 'release_lock' EXIT # Backup current policy backup_policy # Temporary file for atomic write local tmp_policy tmp_policy="$(mktemp /tmp/wg-policy.XXXXXX)" trap "rm -f \"$tmp_policy\" 2>/dev/null; release_lock" EXIT echo '{"clients":{}}' > "$tmp_policy" # Parse peers from wg0.conf # AWK extracts IP and #Access comment per [Peer] block local parse_errors=0 awk ' BEGIN { RS="\n\\[Peer\\]\n"; FS="\n" } NR>1 { ip=""; access=""; internet="false" for(i=1;i<=NF;i++){ if($i ~ /^AllowedIPs/) { split($i,a," = ") gsub(/ /,"",a[2]) split(a[2],b,",") split(b[1],c,"/") ip=c[1] } if($i ~ /^#Access/) { sub(/^#Access[ \t]*=?[ \t]*/, "", $i) access=$i } if($i ~ /^#Internet/) { if(tolower($i) ~ /true|yes|1|allow/) { internet="true" } } } if(ip!="" && ip!="0.0.0.0" && ip!="::") { printf "%s|%s|%s\n", ip, access, internet } } ' "$WG_CONF" | while IFS="|" read -r ip access_string internet_flag; do # === VALIDATE CLIENT IP === if ! validate_cidr "$ip"; then log_warn "Invalid client IP skipped: '$ip'" (( parse_errors++ )) || true continue fi # === PARSE AND VALIDATE ACCESS TARGETS === local ACCESS_JSON="[]" if [[ -n "$access_string" ]]; then # Split by ; and , then validate each entry local valid_targets=() local IFS_OLD="$IFS" IFS=';,' read -ra targets <<< "$access_string" IFS="$IFS_OLD" for target in "${targets[@]}"; do # Trim whitespace target="$(echo "$target" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" [[ -z "$target" ]] && continue if validate_cidr "$target"; then valid_targets+=("$target") else log_warn "Invalid access target skipped for $ip: '$target'" (( parse_errors++ )) || true fi done if (( ${#valid_targets[@]} > 0 )); then ACCESS_JSON=$(printf '%s\n' "${valid_targets[@]}" | jq -R . | jq -s .) fi fi # Write to temp policy jq --arg ip "$ip" --argjson access "$ACCESS_JSON" --argjson internet "$internet_flag" \ '.clients[$ip] = {"name": $ip, "access": $access, "internet": $internet}' \ "$tmp_policy" > "${tmp_policy}.tmp" && mv "${tmp_policy}.tmp" "$tmp_policy" done # Validate JSON before atomic move if ! jq empty "$tmp_policy" 2>/dev/null; then log_error "Generated JSON is invalid, aborting. Check $tmp_policy" exit 1 fi # Atomic move (same filesystem = atomic rename) mv -f "$tmp_policy" "$POLICY_FILE" log_info "policy.json updated successfully" if (( parse_errors > 0 )); then log_warn "$parse_errors validation errors encountered (see warnings above)" fi local client_count client_count=$(jq '(.clients // {}) | length' "$POLICY_FILE") log_info "Total clients in policy: $client_count" } main "$@" 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 set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" SYNC_SCRIPT="/usr/local/bin/wg-sync-policy.sh" ENGINE_SCRIPT="/usr/local/bin/wg-policy-engine.sh" HEALTH_INTERVAL=300 # Health check every 5 minutes LAST_HEALTH=0 # === Validate prerequisites === if ! command -v inotifywait &>/dev/null; then log_error "inotifywait not found. Install: apt install inotify-tools" exit 1 fi if [[ ! -f "$WG_CONF" ]]; then log_error "WireGuard config not found: $WG_CONF" exit 1 fi # === Main watcher loop === log_info "Monitoring $WG_CONF for changes (debounce: ${DEBOUNCE_SEC}s)..." log_info "Health check interval: ${HEALTH_INTERVAL}s" inotifywait -m -e close_write,move,create \ --format '%e %f' \ "$(dirname "$WG_CONF")" 2>/dev/null | \ while read -r events filename; do # Only react to wg0.conf changes [[ "$filename" != "$(basename "$WG_CONF")" ]] && continue log_info "Detected change: $events $filename" # Debounce: wait until no more events for DEBOUNCE_SEC while IFS= read -r -t "$DEBOUNCE_SEC" _dummy; do : # Drain events within debounce window done log_info "Debounce complete, applying changes..." # Run sync if retry "$SYNC_SCRIPT"; then log_info "Sync successful, running engine..." # Run engine with retry if retry "$ENGINE_SCRIPT"; then log_info "Policy engine applied successfully" else log_error "Policy engine FAILED after retries" fi else log_error "Policy sync FAILED after retries" fi # Periodic health check local now now=$(date +%s) if (( now - LAST_HEALTH >= HEALTH_INTERVAL )); then LAST_HEALTH=$now log_info "=== Periodic Health Check ===" health_check || log_warn "Health check reported issues" fi done EOF_WG_SYNC_WATCH_SH cat << 'EOF_WG_POLICY_CTL' > /usr/local/bin/wg-policy-ctl #!/bin/bash # wg-policy-ctl — CLI management tool for WireGuard Policy Firewall set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" usage() { cat < Commands: status Show full health check report policy Display current policy.json formatted 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 + nftables stats Show connection and rule statistics validate Validate policy.json without applying help Show this help EOF } cmd_status() { echo "=========================================" echo " WireGuard Policy Firewall Status" echo " $(date '+%Y-%m-%d %H:%M:%S')" echo "=========================================" echo "" health_check } cmd_policy() { if [[ -f "$POLICY_FILE" ]]; then jq '.' "$POLICY_FILE" else log_error "policy.json not found" exit 1 fi } cmd_rules() { echo "=== Table: $NFT_TABLE_FULL ===" if nft list table "$NFT_TABLE_FULL" 2>/dev/null; then echo "" else echo "(table not found)" fi } cmd_ipset() { 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)" echo "" fi done } cmd_log() { echo "=== Recent WG_DROP log entries ===" (journalctl -k --no-pager -n 50 2>/dev/null || dmesg | tail -50) | grep "$LOG_PREFIX" || echo "(no entries)" } cmd_reload() { log_info "Force reloading policy..." if retry /usr/local/bin/wg-sync-policy.sh; then if retry /usr/local/bin/wg-policy-engine.sh; then log_info "Reload complete" else log_error "Engine failed" exit 1 fi else log_error "Sync failed" exit 1 fi } cmd_backup() { backup_policy backup_nftables log_info "Manual backup complete. Files in: $BACKUP_DIR" } cmd_stats() { echo "=== Client Count ===" jq '(.clients // {}) | length' "$POLICY_FILE" 2>/dev/null || echo "N/A" echo "" echo "=== Clients with Access ===" jq -r '.clients // {} | to_entries[] | select(.value.access | length > 0) | "\(.key): \(.value.access | join(", "))"' "$POLICY_FILE" 2>/dev/null || echo "N/A" echo "" echo "=== Clients without Access (Internet Only) ===" jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A" echo "" echo "=== Active nftables rules ===" nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '#' || echo "N/A" echo "" echo "=== nft set entries ===" for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do local count 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 } cmd_validate() { log_info "Validating policy.json..." if [[ ! -f "$POLICY_FILE" ]]; then log_error "File not found: $POLICY_FILE" exit 1 fi if ! jq empty "$POLICY_FILE" 2>/dev/null; then log_error "Invalid JSON" exit 1 fi local errors=0 local total=0 jq -r '.clients // {} | to_entries[] | "\(.key)|\(.value.access // [] | join(","))"' "$POLICY_FILE" | \ while IFS="|" read -r ip access_str; do (( total++ )) if ! validate_cidr "$ip"; then log_error "Invalid client IP: $ip" (( errors++ )) || true fi if [[ -n "$access_str" ]]; then IFS=',' read -ra targets <<< "$access_str" for target in "${targets[@]}"; do if ! validate_cidr "$target"; then log_error "Invalid access target for $ip: $target" (( errors++ )) || true fi done fi done if (( errors > 0 )); then log_error "Validation failed: $errors errors" exit 1 else log_info "Validation passed: $total clients, 0 errors" fi } # === DISPATCH === case "${1:-help}" in status) cmd_status ;; policy) cmd_policy ;; rules) cmd_rules ;; ipset) cmd_ipset ;; log) cmd_log ;; reload) cmd_reload ;; backup) cmd_backup ;; stats) cmd_stats ;; validate) cmd_validate ;; help|*) usage ;; esac EOF_WG_POLICY_CTL echo "Writing systemd units to /etc/systemd/system/..." cat << 'EOF_WG_POLICY_SERVICE' > /etc/systemd/system/wg-policy.service [Unit] Description=WireGuard Dynamic Policy Firewall Watcher After=network-online.target wg-quick@wg0.service Wants=wg-quick@wg0.service network-online.target StartLimitIntervalSec=60 StartLimitBurst=5 [Service] Type=simple ExecStartPre=/usr/local/bin/wg-sync-policy.sh ExecStart=/usr/local/bin/wg-sync-watch.sh ExecStopPost=/usr/local/bin/wg-policy-cleanup.sh Restart=always RestartSec=10 User=root StandardOutput=journal StandardError=journal SyslogIdentifier=wg-policy # Hardening ProtectSystem=strict ReadWritePaths=/etc/wireguard /var/lock /tmp ProtectHome=yes NoNewPrivileges=no PrivateTmp=yes [Install] WantedBy=multi-user.target EOF_WG_POLICY_SERVICE cat << 'EOF_WG_POLICY_HEALTH_SERVICE' > /etc/systemd/system/wg-policy-health.service [Unit] Description=WireGuard Policy Health Check [Service] Type=oneshot 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 [Unit] Description=WireGuard Policy Health Check Timer [Timer] OnBootSec=60 OnUnitActiveSec=300 AccuracySec=30 [Install] WantedBy=timers.target EOF_WG_POLICY_HEALTH_TIMER chmod +x /usr/local/bin/wg-*.sh /usr/local/bin/wg-policy-ctl 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" } uninstall_policy() { echo "Uninstalling WireGuard Policy Firewall..." echo "Stopping and disabling services..." systemctl disable --now wg-policy.service wg-policy-health.timer wg-policy-health.service 2>/dev/null || true echo "Running cleanup script..." if [ -x /usr/local/bin/wg-policy-cleanup.sh ]; then /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 rm -f /etc/systemd/system/wg-policy-health.timer systemctl daemon-reload echo "Removing scripts from /usr/local/bin/..." rm -f /usr/local/bin/wg-policy-lib.sh rm -f /usr/local/bin/wg-sync-policy.sh rm -f /usr/local/bin/wg-policy-engine.sh rm -f /usr/local/bin/wg-policy-cleanup.sh rm -f /usr/local/bin/wg-sync-watch.sh rm -f /usr/local/bin/wg-policy-ctl echo "Uninstallation complete!" } case "${1:-}" in install) install_policy ;; uninstall) uninstall_policy ;; *) echo "Usage: $0 {install|uninstall}" exit 1 ;; esac