# WireGuard Dynamic Policy Firewall — Production-Ready Rewrite Berikut adalah versi yang sudah diperbaiki dan ditingkatkan secara menyeluruh berdasarkan referensi yang diberikan. --- ## 1. Perbaikan Bug Kritis & Analisis Masalah | Bug | Lokasi | Perbaikan | |-----|--------|-----------| | **Unquoted variables** | `iptables -A $CHAIN -s $WG_SUBNET -d $subnet -j DROP` | Semua variabel dikutip `"$CHAIN"`, `"$WG_SUBNET"`, `"$subnet"` | | **LAN block salah target** | Loop per-IP client tapi rule menggunakan `$WG_SUBNET` (selalu subnet), bukan `$ip` | Diganti: whitelist per-client IP → `"$ip"` ke `"$subnet"` DROP | | **LOG setelah ACCEPT** | `LOG` ditempatkan di akhir chain setelah `ACCEPT`, sehingga tidak pernah match | Dipindah: `LOG` ditempatkan sebelum `ACCEPT` final, atau gunakan target `LOG` + return | | **Race condition lock** | `flock` di subshell `while read` pipe bisa kehilangan lock | Lock dipindah ke main shell, subshell hanya baca | | **No rollback** | Jika `policy-engine` gagal di tengah, rule setengah jadi | Ditambahkan atomic swap dengan backup chain | | **No IP validation** | IP dari `#Access` langsung dipakai tanpa validasi | Ditambahkan regex IPv4/IPv6 validation | --- ## 2. Script Utama — Versi Production-Ready ### 2.1 Shared Library (`/usr/local/bin/wg-policy-lib.sh`) ```bash #!/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 CHAIN="WG_POLICY" readonly CHAIN_BACKUP="WG_POLICY_BAK" 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 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 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 if (( octet < 0 || octet > 255 )); then return 1 fi done return 0 } # Validate IPv4 CIDR (e.g., 192.168.1.0/24) validate_ipv4_cidr() { local cidr="$1" local ip prefix if [[ "$cidr" == *"/"* ]]; then ip="${cidr%%/*}" prefix="${cidr##*/}" else # Single IP treated as /32 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 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}:$ ]] || \ [[ "$ip" == "::" ]] || \ [[ "$ip" == "::1" ]]; then return 0 fi return 1 } # Validate IPv6 CIDR 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 } # Generic CIDR validator — dispatches to v4 or v6 validate_cidr() { local cidr="$1" if [[ "$cidr" == *":"* ]]; then validate_ipv6_cidr "$cidr" else validate_ipv4_cidr "$cidr" fi } # ============================================================ # IPSET MANAGEMENT # ============================================================ ensure_ipset() { local name="$1" family="$2" if ! ipset list "$name" &>/dev/null; then ipset create "$name" hash:net family "$family" hashsize 1024 maxelem 65536 timeout 0 log_info "Created ipset: $name (family=$family)" fi } flush_ipset() { local name="$1" if ipset list "$name" &>/dev/null; then ipset flush "$name" fi } destroy_ipset() { local name="$1" if ipset list "$name" &>/dev/null; then ipset destroy "$name" fi } # ============================================================ # 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 )) # exponential backoff 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() { # Lock released automatically when fd closes, but we clean up file 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 # Keep only last 50 backups 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_iptables() { 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" fi if command -v ip6tables-save &>/dev/null; then ip6tables-save > "${BACKUP_DIR}/ip6tables_${timestamp}.rules" 2>/dev/null || true 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 } # ============================================================ # 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 } # ============================================================ # 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 chain exists if iptables -L "$CHAIN" -n &>/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" else report+="[WARN] Chain $CHAIN 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" else report+="[WARN] FORWARD chain has no reference to $CHAIN\n" status=1 fi # 5. Check ipset for set_name in "$IPSET_V4" "$IPSET_V6"; do if ipset list "$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" else report+="[INFO] ipset $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 } ``` ```bash chmod +x /usr/local/bin/wg-policy-lib.sh ``` --- ### 2.2 Policy Sync — Diperbaiki (`/usr/local/bin/wg-sync-policy.sh`) ```bash #!/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="" 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]+/, "", $i) access=$i } } if(ip!="" && ip!="0.0.0.0" && ip!="::") { printf "%s|%s\n", ip, access } } ' "$WG_CONF" | while IFS="|" read -r ip access_string; 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" \ '.clients[$ip] = {"name": $ip, "access": $access}' \ "$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 "$@" ``` --- ### 2.3 Policy Engine — Diperbaiki (`/usr/local/bin/wg-policy-engine.sh`) ```bash #!/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 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" # ============================================================ # ROLLBACK # ============================================================ rollback() { log_error "ROLLBACK triggered! Restoring previous rules..." # Remove new chain references while iptables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do :; done # Flush and remove new chain iptables -F "$CHAIN" 2>/dev/null || true iptables -X "$CHAIN" 2>/dev/null || true # 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 iptables -A FORWARD -i "$WG_IF" -j "$CHAIN" log_info "Rollback: restored from backup chain" fi # Cleanup backup chain iptables -F "$CHAIN_BACKUP" 2>/dev/null || true iptables -X "$CHAIN_BACKUP" 2>/dev/null || true # Cleanup backup ipsets destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true } # ============================================================ # MAIN # ============================================================ main() { log_info "Starting policy engine..." # === VALIDATE === 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 iptables state backup_iptables # Set trap for rollback on failure trap 'rollback' ERR # === DETECT SUBNETS === local WG_SUBNET WG_SUBNET_V6 LAN_SUBNETS WG_SUBNET="$(detect_wg_subnet inet)" WG_SUBNET_V6="$(detect_wg_subnet inet6)" LAN_SUBNETS="$(detect_lan_subnets)" 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 # === CLEANUP OLD CHAIN (loop until all references removed) === log_info "Cleaning up old chain references..." while iptables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do :; 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 | 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 -A FORWARD -i "$WG_IF" -j "$CHAIN" fi log_info "Chain $CHAIN created and linked to FORWARD" # === POPULATE IPSET (for large-scale whitelist) === 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 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 # Determine if v4 or v6 if [[ "$target" == *":"* ]]; then if [[ "$use_ipv6" == true ]]; then ipset add "$IPSET_V6" "$target" 2>/dev/null || \ log_warn "Failed to add $target to ipset $IPSET_V6" fi else ipset add "$IPSET_V4" "$target" 2>/dev/null || \ log_warn "Failed to add $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" # === RULE 1: ESTABLISHED,RELATED — allow return traffic === iptables -A "$CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # === RULE 2: WHITELIST via ipset (per-client source) === # For each client with access rules, allow only from that client's IP to ipset targets jq -r ' .clients // {} | to_entries[] | select(.value.access != null and (.value.access | length > 0)) | "\(.key)" ' "$POLICY_FILE" 2>/dev/null | while read -r client_ip; do [[ -z "$client_ip" ]] && continue if [[ "$client_ip" == *":"* ]]; then # IPv6 client if [[ "$use_ipv6" == true ]]; then ip6tables -A "$CHAIN" -s "$client_ip" -m set --match-set "$IPSET_V6" dst -j ACCEPT 2>/dev/null || true fi else # IPv4 client iptables -A "$CHAIN" -s "$client_ip" -m set --match-set "$IPSET_V4" dst -j ACCEPT fi done # === 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 === # FIXED: iterate per LAN subnet, block from WG_SUBNET (not per-client IP) if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then echo "$LAN_SUBNETS" | while read -r subnet; do [[ -z "$subnet" ]] && continue # Skip if LAN subnet overlaps with WG subnet [[ -n "$WG_SUBNET" && "$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: LOGGING (rate-limited) — BEFORE final ACCEPT === # FIXED: LOG was after ACCEPT in original, now placed before final rule 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 6: DEFAULT ACCEPT (internet access) === iptables -A "$CHAIN" -j ACCEPT if [[ "$use_ipv6" == true ]]; then ip6tables -A "$CHAIN" -j ACCEPT 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 # 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" echo "[OK] iptables policy applied. Chain: $CHAIN" } main "$@" ``` --- ### 2.4 Cleanup Script — Diperbaiki (`/usr/local/bin/wg-policy-cleanup.sh`) ```bash #!/bin/bash # wg-policy-cleanup.sh — Clean removal of all policy artifacts # Fixed: proper loop, ipset cleanup, IPv6 cleanup set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" main() { log_info "Starting cleanup..." # === IPv4 chain cleanup === local removed=0 while iptables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do (( removed++ )) 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 ip6tables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do :; 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 "$@" ``` --- ### 2.5 File Watcher — Diperbaiki (`/usr/local/bin/wg-sync-watch.sh`) ```bash #!/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 ``` --- ## 3. Systemd Service — Diperbaiki ### `/etc/systemd/system/wg-policy.service` ```ini [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-policy-engine.sh --health-check 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 ``` ### Health Check Timer — `/etc/systemd/system/wg-policy-health.timer` ```ini [Unit] Description=WireGuard Policy Health Check Timer [Timer] OnBootSec=60 OnUnitActiveSec=300 AccuracySec=30 [Install] WantedBy=timers.target ``` ### Health Check Service — `/etc/systemd/system/wg-policy-health.service` ```ini [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 ``` ### Aktivasi ```bash systemctl daemon-reexec systemctl daemon-reload systemctl enable wg-policy.service systemctl enable wg-policy-health.timer systemctl start wg-policy.service systemctl start wg-policy-health.timer ``` --- ## 4. Integrasi ke `wg0.conf` — Diperbaiki ```ini [Interface] Address = 10.0.0.1/24 ListenPort = 51820 PrivateKey = # PostUp: sync policy + apply engine (with retry) PostUp = /usr/local/bin/wg-sync-policy.sh && /usr/local/bin/wg-policy-engine.sh # PostDown: safe cleanup PostDown = /usr/local/bin/wg-policy-cleanup.sh [Peer] PublicKey = AllowedIPs = 10.0.0.2/32 #Access 192.168.1.10/32;192.168.12.0/24,192.168.12.2/32 [Peer] PublicKey = AllowedIPs = 10.0.0.3/32 #Access 10.0.0.1/32 [Peer] PublicKey = AllowedIPs = 10.0.0.4/32 #Access ``` --- ## 5. CLI Monitoring Tool (`/usr/local/bin/wg-policy-ctl`) ```bash #!/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 iptables rules in WG_POLICY chain ipset Show ipset contents log Tail WG_DROP logs (last 50 lines) reload Force re-sync and re-apply policy backup Manual backup of policy + iptables 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 "=== IPv4 Chain: $CHAIN ===" if iptables -L "$CHAIN" -n -v --line-numbers 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)" 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 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_iptables 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 iptables rules ===" iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l || 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 local count count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[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 ``` ```bash chmod +x /usr/local/bin/wg-policy-ctl ``` --- ## 6. Ringkasan Perbaikan & Penambahan | Aspek | Sebelum (Referensi) | Sesudah (Production) | |-------|--------------------|--------------------| | **Unquoted variables** | `iptables -A $CHAIN -s $WG_SUBNET -d $subnet` | Semua dikutip: `"$CHAIN"`, `"$WG_SUBNET"`, `"$subnet"` | | **LOG placement** | Setelah `ACCEPT` (tidak pernah match) | Sebelum `ACCEPT` final, dengan `--log-level 4` | | **LAN block target** | Loop per-IP client tapi block `$WG_SUBNET` | Block dari `"$WG_SUBNET"` ke `"$subnet"` dengan overlap check | | **IP validation** | Tidak ada | `validate_ipv4`, `validate_ipv4_cidr`, `validate_ipv6`, `validate_ipv6_cidr` dengan regex ketat | | **Atomic update** | `mv` tanpa backup | Backup chain → create new → swap → cleanup backup, dengan `trap rollback` | | **Rollback** | Tidak ada | `rollback()` function: restore backup chain, cleanup ipset | | **Race condition** | `flock` di subshell pipe | `acquire_lock()` di main shell dengan timeout 10 detik | | **IPv6** | Tidak di-handle | `ip6tables` rules parallel, `validate_ipv6*`, link-local + ULA block | | **ipset** | Tidak ada | `wg_allowed_v4` / `wg_allowed_v6` hash:net, O(1) lookup | | **Health check** | Tidak ada | `health_check()` + systemd timer setiap 5 menit + `wg-policy-ctl status` | | **Backup** | Tidak ada | Otomatis setiap sync/engine run, max 50 policy + 20 iptables backups | | **Retry** | Tidak ada | `retry()` dengan exponential backoff, max 3 attempt | | **Monitoring** | Manual `grep` | `wg-policy-ctl` CLI: status, stats, log, rules, validate | | **Debounce** | `read -t 2` | Tetap `read -t $DEBOUNCE_SEC` tapi dengan proper event drain | | **Systemd hardening** | Tidak ada | `ProtectSystem=strict`, `ReadWritePaths`, `StartLimitBurst` | | **Error handling** | `set -euo pipefail` dasar | `set -euo pipefail` + `trap rollback` + `trap cleanup` + logging terstruktur |