Files
wireguard-vpn/.opencode/01.wireguard-policy-firewall.md
T

37 KiB

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)

#!/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
}
chmod +x /usr/local/bin/wg-policy-lib.sh

2.2 Policy Sync — Diperbaiki (/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=""
        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(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)

#!/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)

#!/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)

#!/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

[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

[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

[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

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

[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key>

# 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 = <client1-pubkey>
AllowedIPs = 10.0.0.2/32
#Access 192.168.1.10/32;192.168.12.0/24,192.168.12.2/32

[Peer]
PublicKey = <client2-pubkey>
AllowedIPs = 10.0.0.3/32
#Access 10.0.0.1/32

[Peer]
PublicKey = <client3-pubkey>
AllowedIPs = 10.0.0.4/32
#Access

5. CLI Monitoring Tool (/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 <<EOF
Usage: $(basename "$0") <command>

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
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