From 0b19a9061f0dbb51760375363343e58fdda46e07 Mon Sep 17 00:00:00 2001 From: datadunia Date: Fri, 1 May 2026 13:47:50 +0700 Subject: [PATCH] fix: resolve ghost iptables rules, set -e crashes, and automate MASQUERADE NAT --- .opencode/01.wireguard-policy-firewall.md | 153 +++++++++++++----- README.md | 17 +- wg-policy-cleanup.sh | 33 +++- wg-policy-ctl | 186 ++++++++++++++++++++++ wg-policy-engine.sh | 111 +++++++++---- wg-policy-lib.sh | 12 +- wg-sync-policy.sh | 15 +- 7 files changed, 432 insertions(+), 95 deletions(-) create mode 100644 wg-policy-ctl diff --git a/.opencode/01.wireguard-policy-firewall.md b/.opencode/01.wireguard-policy-firewall.md index e85ff4c..26eee0e 100644 --- a/.opencode/01.wireguard-policy-firewall.md +++ b/.opencode/01.wireguard-policy-firewall.md @@ -302,6 +302,16 @@ detect_wg_subnet() { 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 # ============================================================ @@ -439,7 +449,7 @@ main() { awk ' BEGIN { RS="\n\\[Peer\\]\n"; FS="\n" } NR>1 { - ip=""; access="" + ip=""; access=""; internet="false" for(i=1;i<=NF;i++){ if($i ~ /^AllowedIPs/) { split($i,a," = ") @@ -452,12 +462,17 @@ main() { 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\n", ip, access + printf "%s|%s|%s\n", ip, access, internet } } - ' "$WG_CONF" | while IFS="|" read -r ip access_string; do + ' "$WG_CONF" | while IFS="|" read -r ip access_string internet_flag; do # === VALIDATE CLIENT IP === if ! validate_cidr "$ip"; then @@ -497,8 +512,8 @@ main() { fi # Write to temp policy - jq --arg ip "$ip" --argjson access "$ACCESS_JSON" \ - '.clients[$ip] = {"name": $ip, "access": $access}' \ + 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 @@ -622,14 +637,38 @@ main() { 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 + if command -v ip6tables &>/dev/null; then + sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true + fi + + if ! iptables -C FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; then + iptables -I FORWARD 1 -o "$WG_IF" -j ACCEPT + log_info "Added FORWARD rule for return traffic to $WG_IF" + fi + + if ! iptables -t nat -C POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; then + iptables -t nat -A POSTROUTING -o "$DEF_IF" -j MASQUERADE + log_info "Enabled IPv4 MASQUERADE on $DEF_IF" + fi + # === 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 + while true; do + local rline="" + rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$rline" ]]; then + iptables -D FORWARD "$rline" 2>/dev/null || break + else + break + fi + done # Backup existing chain before flushing if iptables -L "$CHAIN" -n &>/dev/null; then iptables -N "$CHAIN_BACKUP" 2>/dev/null || iptables -F "$CHAIN_BACKUP" - iptables-save -c | grep "^-A $CHAIN" | \ + iptables-save -c 2>/dev/null | grep "^-A $CHAIN" | \ sed "s/-A $CHAIN/-A $CHAIN_BACKUP/" | \ iptables-restore -c 2>/dev/null || true log_info "Backed up existing chain to $CHAIN_BACKUP" @@ -646,7 +685,7 @@ main() { fi log_info "Chain $CHAIN created and linked to FORWARD" - # === POPULATE IPSET (for large-scale whitelist) === + # === POPULATE IPSET (hash:net,net for source->target mapping) === local use_ipset=false if has_ipset; then use_ipset=true @@ -663,7 +702,7 @@ main() { flush_ipset "$IPSET_V6" fi - # Read all access entries and populate ipset + # Read all access entries and populate ipset (client_ip,target) jq -r ' .clients // {} | to_entries[] | select(.value.access != null and (.value.access | length > 0)) | @@ -675,12 +714,12 @@ main() { 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" + ipset add "$IPSET_V6" "${client_ip},${target}" 2>/dev/null || \ + log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V6" fi else - ipset add "$IPSET_V4" "$target" 2>/dev/null || \ - log_warn "Failed to add $target to ipset $IPSET_V4" + ipset add "$IPSET_V4" "${client_ip},${target}" 2>/dev/null || \ + log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V4" fi done @@ -701,21 +740,10 @@ main() { # === RULE 2: WHITELIST (per-client source) === if [[ "$use_ipset" == true ]]; then - 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 - 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 - iptables -A "$CHAIN" -s "$client_ip" -m set --match-set "$IPSET_V4" dst -j ACCEPT - fi - done + iptables -A "$CHAIN" -m set --match-set "$IPSET_V4" src,dst -j ACCEPT + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -m set --match-set "$IPSET_V6" src,dst -j ACCEPT 2>/dev/null || true + fi else jq -r ' .clients // {} | to_entries[] | @@ -777,8 +805,24 @@ main() { 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 + # === RULE 5: INTERNET ACCESS (#Internet = true) === + jq -r ' + .clients // {} | to_entries[] | + select(.value.internet == "true") | + "\(.key)" + ' "$POLICY_FILE" 2>/dev/null | while read -r client_ip; do + [[ -z "$client_ip" ]] && continue + + if [[ "$client_ip" == *":"* ]]; then + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -s "$client_ip" -j ACCEPT 2>/dev/null || true + fi + else + iptables -A "$CHAIN" -s "$client_ip" -j ACCEPT + fi + done + + # === RULE 6: LOGGING (rate-limited) — BEFORE final DROP === iptables -A "$CHAIN" \ -m limit --limit "$LOG_RATE" \ -j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4 @@ -789,11 +833,11 @@ main() { -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 + # === RULE 7: DEFAULT DROP (internet block by default) === + iptables -A "$CHAIN" -j DROP if [[ "$use_ipv6" == true ]]; then - ip6tables -A "$CHAIN" -j ACCEPT 2>/dev/null || true + ip6tables -A "$CHAIN" -j DROP 2>/dev/null || true fi # === CLEANUP BACKUP CHAIN (no rollback needed anymore) === @@ -821,9 +865,8 @@ main "$@" ```bash #!/bin/bash # wg-policy-cleanup.sh — Clean removal of all policy artifacts -# Fixed: proper loop, ipset cleanup, IPv6 cleanup -set -euo pipefail +set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" @@ -831,10 +874,26 @@ source "${SCRIPT_DIR}/wg-policy-lib.sh" main() { log_info "Starting cleanup..." + local DEF_IF + DEF_IF="$(detect_default_if)" + + # === Base Routing Cleanup === + while iptables -D FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; do :; done + while iptables -t nat -D POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; do :; done + log_info "Removed base routing and NAT rules" + # === IPv4 chain cleanup === local removed=0 - while iptables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do - (( removed++ )) + + while true; do + local line="" + line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$line" ]]; then + iptables -D FORWARD "$line" 2>/dev/null || break + (( removed++ )) + else + break + fi done if (( removed > 0 )); then @@ -850,7 +909,15 @@ main() { # === IPv6 chain cleanup === if command -v ip6tables &>/dev/null; then - while ip6tables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do :; done + while true; do + local line6="" + line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$line6" ]]; then + ip6tables -D FORWARD "$line6" 2>/dev/null || break + else + break + fi + done ip6tables -F "$CHAIN" 2>/dev/null || true ip6tables -X "$CHAIN" 2>/dev/null || true ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true @@ -1030,16 +1097,18 @@ systemctl start wg-policy-health.timer ## 4. Integrasi ke `wg0.conf` — Diperbaiki +> **⚠️ WireGuard does NOT support multiline values.** Every command must be on a `PostUp = ...` or `PostDown = ...` line. Bare commands without the `PostUp =` prefix will cause `Configuration parsing error`. + ```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 +# Policy engine: auto-handles NAT, IP forwarding, and dynamic firewall rules +PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh -# PostDown: safe cleanup +# Policy engine: cleanup all firewall and routing traces PostDown = /usr/local/bin/wg-policy-cleanup.sh [Peer] @@ -1050,7 +1119,7 @@ AllowedIPs = 10.0.0.2/32 [Peer] PublicKey = AllowedIPs = 10.0.0.3/32 -#Access 10.0.0.1/32 +#Access = 10.0.0.1/32 [Peer] PublicKey = diff --git a/README.md b/README.md index fb878f0..f761f10 100644 --- a/README.md +++ b/README.md @@ -54,28 +54,27 @@ To integrate the engine, you need to append hooks into your `wg0.conf` interface ### 1. Interface Block (Hooks) Add the `PostUp` and `PostDown` scripts so the engine initializes correctly during VPN startup and removes traces upon shutdown. +> **⚠️ WireGuard does NOT support multiline values.** Every command must be on a `PostUp = ...` or `PostDown = ...` line. Bare commands without the `PostUp =` prefix will cause `Configuration parsing error`. + ```ini [Interface] Address = 10.0.0.1/24 ListenPort = 51820 PrivateKey = -# PostUp: sync policy + apply engine -# default PostUp -iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; -# modification -PostUp = /usr/local/bin/wg-sync-policy.sh && /usr/local/bin/wg-policy-engine.sh +# Policy engine: auto-handles NAT, IP forwarding, and dynamic firewall rules +PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh -# PostDown: safe cleanup -# default PostUp -iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; -# modification +# Policy engine: cleanup all firewall and routing traces PostDown = /usr/local/bin/wg-policy-cleanup.sh ``` ### 2. Peer Block (`#Access` Tags) For each client, use the `#Access` comment line. Define the destinations (targets) the peer is allowed to access. You can separate multiple IPs or CIDRs with commas or semicolons. +> **⚠️ WARNING: Do NOT use `SaveConfig = true`!** +> WireGuard's `SaveConfig` feature overwrites `wg0.conf` directly and **strips all comments**, which will permanently delete all `#Access` tags. If you are using a Web UI/Dashboard, make sure it does not strip unknown comments when saving. + ```ini [Peer] PublicKey = diff --git a/wg-policy-cleanup.sh b/wg-policy-cleanup.sh index 9a4e15b..be1cd35 100644 --- a/wg-policy-cleanup.sh +++ b/wg-policy-cleanup.sh @@ -1,8 +1,7 @@ #!/bin/bash # wg-policy-cleanup.sh — Clean removal of all policy artifacts -# Fixed: proper loop, ipset cleanup, IPv6 cleanup -set -euo pipefail +set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/wg-policy-lib.sh" @@ -10,10 +9,26 @@ source "${SCRIPT_DIR}/wg-policy-lib.sh" main() { log_info "Starting cleanup..." + local DEF_IF + DEF_IF="$(detect_default_if)" + + # === Base Routing Cleanup === + while iptables -D FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; do :; done + while iptables -t nat -D POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; do :; done + log_info "Removed base routing and NAT rules" + # === IPv4 chain cleanup === local removed=0 - while iptables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do - (( removed++ )) + + while true; do + local line="" + line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$line" ]]; then + iptables -D FORWARD "$line" 2>/dev/null || break + (( removed++ )) + else + break + fi done if (( removed > 0 )); then @@ -29,7 +44,15 @@ main() { # === IPv6 chain cleanup === if command -v ip6tables &>/dev/null; then - while ip6tables -D FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; do :; done + while true; do + local line6="" + line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$line6" ]]; then + ip6tables -D FORWARD "$line6" 2>/dev/null || break + else + break + fi + done ip6tables -F "$CHAIN" 2>/dev/null || true ip6tables -X "$CHAIN" 2>/dev/null || true ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true diff --git a/wg-policy-ctl b/wg-policy-ctl new file mode 100644 index 0000000..8637744 --- /dev/null +++ b/wg-policy-ctl @@ -0,0 +1,186 @@ +#!/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 \ No newline at end of file diff --git a/wg-policy-engine.sh b/wg-policy-engine.sh index 476d40f..4db0422 100644 --- a/wg-policy-engine.sh +++ b/wg-policy-engine.sh @@ -16,7 +16,15 @@ 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 + while true; do + local rline="" + rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$rline" ]]; then + iptables -D FORWARD "$rline" 2>/dev/null || break + else + break + fi + done # Flush and remove new chain iptables -F "$CHAIN" 2>/dev/null || true @@ -52,6 +60,11 @@ 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 @@ -69,11 +82,12 @@ main() { trap 'rollback' ERR # === DETECT SUBNETS === - local WG_SUBNET WG_SUBNET_V6 LAN_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" @@ -90,14 +104,41 @@ main() { echo "$LAN_SUBNETS" | while read -r s; do log_info " $s"; done fi + # === BASE ROUTING & NAT === + # Enable IP Forwarding + sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true + if command -v ip6tables &>/dev/null; then + sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true + fi + + # Allow return traffic to wg interface + if ! iptables -C FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; then + iptables -I FORWARD 1 -o "$WG_IF" -j ACCEPT + log_info "Added FORWARD rule for return traffic to $WG_IF" + fi + + # Setup MASQUERADE on default interface + if ! iptables -t nat -C POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; then + iptables -t nat -A POSTROUTING -o "$DEF_IF" -j MASQUERADE + log_info "Enabled IPv4 MASQUERADE on $DEF_IF" + fi + # === 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 + while true; do + local rline="" + rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$rline" ]]; then + iptables -D FORWARD "$rline" 2>/dev/null || break + else + break + fi + done # Backup existing chain before flushing if iptables -L "$CHAIN" -n &>/dev/null; then iptables -N "$CHAIN_BACKUP" 2>/dev/null || iptables -F "$CHAIN_BACKUP" - iptables-save -c | grep "^-A $CHAIN" | \ + iptables-save -c 2>/dev/null | grep "^-A $CHAIN" | \ sed "s/-A $CHAIN/-A $CHAIN_BACKUP/" | \ iptables-restore -c 2>/dev/null || true log_info "Backed up existing chain to $CHAIN_BACKUP" @@ -114,7 +155,7 @@ main() { fi log_info "Chain $CHAIN created and linked to FORWARD" - # === POPULATE IPSET (for large-scale whitelist) === + # === POPULATE IPSET (hash:net,net for source->target mapping) === local use_ipset=false if has_ipset; then use_ipset=true @@ -131,7 +172,7 @@ main() { flush_ipset "$IPSET_V6" fi - # Read all access entries and populate ipset + # Read all access entries and populate ipset (client_ip,target) jq -r ' .clients // {} | to_entries[] | select(.value.access != null and (.value.access | length > 0)) | @@ -143,12 +184,12 @@ main() { 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" + ipset add "$IPSET_V6" "${client_ip},${target}" 2>/dev/null || \ + log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V6" fi else - ipset add "$IPSET_V4" "$target" 2>/dev/null || \ - log_warn "Failed to add $target to ipset $IPSET_V4" + ipset add "$IPSET_V4" "${client_ip},${target}" 2>/dev/null || \ + log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V4" fi done @@ -169,21 +210,10 @@ main() { # === RULE 2: WHITELIST (per-client source) === if [[ "$use_ipset" == true ]]; then - 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 - 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 - iptables -A "$CHAIN" -s "$client_ip" -m set --match-set "$IPSET_V4" dst -j ACCEPT - fi - done + iptables -A "$CHAIN" -m set --match-set "$IPSET_V4" src,dst -j ACCEPT + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -m set --match-set "$IPSET_V6" src,dst -j ACCEPT 2>/dev/null || true + fi else jq -r ' .clients // {} | to_entries[] | @@ -224,12 +254,11 @@ main() { 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 + # Skip if LAN subnet exactly matches WG subnet (handled by Rule 3) + [[ "$subnet" == "$WG_SUBNET" ]] && continue iptables -A "$CHAIN" -s "$WG_SUBNET" -d "$subnet" -j DROP log_info "Block: $WG_SUBNET → $subnet" @@ -245,8 +274,24 @@ main() { 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 + # === RULE 5: INTERNET ACCESS (#Internet = true) === + jq -r ' + .clients // {} | to_entries[] | + select(.value.internet == "true") | + "\(.key)" + ' "$POLICY_FILE" 2>/dev/null | while read -r client_ip; do + [[ -z "$client_ip" ]] && continue + + if [[ "$client_ip" == *":"* ]]; then + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -s "$client_ip" -j ACCEPT 2>/dev/null || true + fi + else + iptables -A "$CHAIN" -s "$client_ip" -j ACCEPT + fi + done + + # === RULE 6: LOGGING (rate-limited) — BEFORE final DROP === iptables -A "$CHAIN" \ -m limit --limit "$LOG_RATE" \ -j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4 @@ -257,11 +302,11 @@ main() { -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 + # === RULE 7: DEFAULT DROP (internet block by default) === + iptables -A "$CHAIN" -j DROP if [[ "$use_ipv6" == true ]]; then - ip6tables -A "$CHAIN" -j ACCEPT 2>/dev/null || true + ip6tables -A "$CHAIN" -j DROP 2>/dev/null || true fi # === CLEANUP BACKUP CHAIN (no rollback needed anymore) === diff --git a/wg-policy-lib.sh b/wg-policy-lib.sh index 66a9b39..5efa809 100644 --- a/wg-policy-lib.sh +++ b/wg-policy-lib.sh @@ -137,7 +137,7 @@ ensure_ipset() { local name="$1" family="$2" has_ipset || return 0 if ! ipset list "$name" &>/dev/null; then - ipset create "$name" hash:net family "$family" hashsize 1024 maxelem 65536 timeout 0 + ipset create "$name" hash:net,net family "$family" hashsize 1024 maxelem 65536 timeout 0 log_info "Created ipset: $name (family=$family)" fi } @@ -278,6 +278,16 @@ detect_wg_subnet() { 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 # ============================================================ diff --git a/wg-sync-policy.sh b/wg-sync-policy.sh index e003948..eda3d49 100644 --- a/wg-sync-policy.sh +++ b/wg-sync-policy.sh @@ -46,7 +46,7 @@ main() { awk ' BEGIN { RS="\n\\[Peer\\]\n"; FS="\n" } NR>1 { - ip=""; access="" + ip=""; access=""; internet="false" for(i=1;i<=NF;i++){ if($i ~ /^AllowedIPs/) { split($i,a," = ") @@ -59,12 +59,17 @@ main() { 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\n", ip, access + printf "%s|%s|%s\n", ip, access, internet } } - ' "$WG_CONF" | while IFS="|" read -r ip access_string; do + ' "$WG_CONF" | while IFS="|" read -r ip access_string internet_flag; do # === VALIDATE CLIENT IP === if ! validate_cidr "$ip"; then @@ -104,8 +109,8 @@ main() { fi # Write to temp policy - jq --arg ip "$ip" --argjson access "$ACCESS_JSON" \ - '.clients[$ip] = {"name": $ip, "access": $access}' \ + 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