From 760e0e88fa1411ba18ef521b57a2ce40efb3f352 Mon Sep 17 00:00:00 2001 From: datadunia Date: Fri, 1 May 2026 15:35:31 +0700 Subject: [PATCH] feat: add installer script and builder tools - Added install.sh for easy setup and teardown - Added build.sh and build.bat to dynamically assemble install.sh - Updated README.md with new installation instructions - Fixed bidirectional WG_POLICY FORWARD rule routing in wg-policy-engine.sh --- README.md | 38 +- build.bat | 110 ++++ build.sh | 126 +++++ install.sh | 1312 +++++++++++++++++++++++++++++++++++++++++++ wg-policy-engine.sh | 14 +- 5 files changed, 1577 insertions(+), 23 deletions(-) create mode 100644 build.bat create mode 100644 build.sh create mode 100644 install.sh diff --git a/README.md b/README.md index f761f10..74865eb 100644 --- a/README.md +++ b/README.md @@ -15,22 +15,30 @@ Rather than allowing all VPN clients to reach any part of your internal network, --- -## 📁 File Locations & Installation +## 📁 Installation -All scripts must be placed in `/usr/local/bin/` and made executable. +The easiest way to install is using the provided `install.sh` script, which automatically installs dependencies, copies all scripts to `/usr/local/bin/`, sets up the systemd daemon, and enables the service. -| File | Location | Description | -|------|----------|-------------| -| `wg-policy-lib.sh` | `/usr/local/bin/wg-policy-lib.sh` | Shared library and core validation tools. | -| `wg-sync-policy.sh` | `/usr/local/bin/wg-sync-policy.sh` | Extracts config into atomic JSON format. | -| `wg-policy-engine.sh` | `/usr/local/bin/wg-policy-engine.sh` | Translates JSON into iptables & ipset logic. | -| `wg-policy-cleanup.sh` | `/usr/local/bin/wg-policy-cleanup.sh` | Reverts and cleans up all firewall traces safely. | -| `wg-sync-watch.sh` | `/usr/local/bin/wg-sync-watch.sh` | Debounced file watcher (daemon). | -| `wg-policy-ctl` | `/usr/local/bin/wg-policy-ctl` | Handy command-line interface tool. | - -Make sure they are executable: ```bash -chmod +x /usr/local/bin/wg-*.sh /usr/local/bin/wg-policy-ctl +# Install everything +sudo ./install.sh install + +# Uninstall everything +sudo ./install.sh uninstall +``` + +### Building the Installer (For Developers) + +If you modify any of the source `.sh` or `.service` files, you must rebuild the `install.sh` script using the provided builders. + +**On Linux (Bash):** +```bash +./build.sh +``` + +**On Windows (CMD/PowerShell):** +```cmd +build.bat ``` --- @@ -132,9 +140,9 @@ wg-policy-ctl validate ## 🔧 Systemd Integration (Watcher Daemon) -If you are using the daemon mode to auto-sync changes instantly upon editing `wg0.conf` (without needing to run `wg-policy-ctl reload` or restarting the interface). +If you use the `install.sh` script, the daemon is automatically installed, enabled, and started for you. It monitors `wg0.conf` for changes and triggers the pipeline seamlessly. -### 1. File Installation +### Manual File Installation (If not using install.sh) Place the three provided systemd unit files into `/etc/systemd/system/`. | Systemd File | Location | Description | diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..c0b93b4 --- /dev/null +++ b/build.bat @@ -0,0 +1,110 @@ +@echo off +setlocal enabledelayedexpansion + +set INSTALL_SCRIPT=install.sh + +echo Building %INSTALL_SCRIPT%... + +> "%INSTALL_SCRIPT%" echo #!/bin/bash +>> "%INSTALL_SCRIPT%" echo # WireGuard Policy Firewall Installer/Uninstaller +>> "%INSTALL_SCRIPT%" echo # This file is auto-generated. Do not edit directly. Run build.sh or build.bat instead. +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo set -euo pipefail +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo if [[ $EUID -ne 0 ]]; then +>> "%INSTALL_SCRIPT%" echo echo "This script must be run as root." +>> "%INSTALL_SCRIPT%" echo exit 1 +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo install_policy^(^) { +>> "%INSTALL_SCRIPT%" echo echo "Installing WireGuard Policy Firewall..." +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Checking dependencies..." +>> "%INSTALL_SCRIPT%" echo apt-get update -y ^|^| true +>> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools ipset iptables ^|^| true +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Writing scripts to /usr/local/bin/..." +>> "%INSTALL_SCRIPT%" echo. + +call :AppendFile wg-policy-lib.sh /usr/local/bin/wg-policy-lib.sh EOF_WG_POLICY_LIB +call :AppendFile wg-policy-engine.sh /usr/local/bin/wg-policy-engine.sh EOF_WG_POLICY_ENGINE +call :AppendFile wg-policy-cleanup.sh /usr/local/bin/wg-policy-cleanup.sh EOF_WG_POLICY_CLEANUP +call :AppendFile wg-sync-policy.sh /usr/local/bin/wg-sync-policy.sh EOF_WG_SYNC_POLICY +call :AppendFile wg-sync-watch.sh /usr/local/bin/wg-sync-watch.sh EOF_WG_SYNC_WATCH +call :AppendFile wg-policy-ctl /usr/local/bin/wg-policy-ctl EOF_WG_POLICY_CTL + +>> "%INSTALL_SCRIPT%" echo echo "Writing systemd units to /etc/systemd/system/..." +>> "%INSTALL_SCRIPT%" echo. + +call :AppendFile wg-policy.service /etc/systemd/system/wg-policy.service EOF_WG_POLICY_SERVICE +call :AppendFile wg-policy-health.service /etc/systemd/system/wg-policy-health.service EOF_WG_POLICY_HEALTH_SERVICE +call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.timer EOF_WG_POLICY_HEALTH_TIMER + +>> "%INSTALL_SCRIPT%" echo chmod +x /usr/local/bin/wg-*.sh /usr/local/bin/wg-policy-ctl +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Reloading systemd daemon..." +>> "%INSTALL_SCRIPT%" echo systemctl daemon-reload +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Enabling and starting services..." +>> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy.service +>> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy-health.timer +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Installation complete!" +>> "%INSTALL_SCRIPT%" echo echo "You can check status with: wg-policy-ctl status" +>> "%INSTALL_SCRIPT%" echo } +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo uninstall_policy^(^) { +>> "%INSTALL_SCRIPT%" echo echo "Uninstalling WireGuard Policy Firewall..." +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Stopping and disabling services..." +>> "%INSTALL_SCRIPT%" echo systemctl disable --now wg-policy.service wg-policy-health.timer wg-policy-health.service 2^>/dev/null ^|^| true +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Running cleanup script..." +>> "%INSTALL_SCRIPT%" echo if [ -x /usr/local/bin/wg-policy-cleanup.sh ]; then +>> "%INSTALL_SCRIPT%" echo /usr/local/bin/wg-policy-cleanup.sh ^|^| true +>> "%INSTALL_SCRIPT%" echo fi +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Removing systemd units..." +>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy.service +>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.service +>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.timer +>> "%INSTALL_SCRIPT%" echo systemctl daemon-reload +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Removing scripts from /usr/local/bin/..." +>> "%INSTALL_SCRIPT%" echo rm -f /usr/local/bin/wg-policy-lib.sh +>> "%INSTALL_SCRIPT%" echo rm -f /usr/local/bin/wg-sync-policy.sh +>> "%INSTALL_SCRIPT%" echo rm -f /usr/local/bin/wg-policy-engine.sh +>> "%INSTALL_SCRIPT%" echo rm -f /usr/local/bin/wg-policy-cleanup.sh +>> "%INSTALL_SCRIPT%" echo rm -f /usr/local/bin/wg-sync-watch.sh +>> "%INSTALL_SCRIPT%" echo rm -f /usr/local/bin/wg-policy-ctl +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo echo "Uninstallation complete!" +>> "%INSTALL_SCRIPT%" echo } +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo case "${1:-}" in +>> "%INSTALL_SCRIPT%" echo install^) +>> "%INSTALL_SCRIPT%" echo install_policy +>> "%INSTALL_SCRIPT%" echo ;; +>> "%INSTALL_SCRIPT%" echo uninstall^) +>> "%INSTALL_SCRIPT%" echo uninstall_policy +>> "%INSTALL_SCRIPT%" echo ;; +>> "%INSTALL_SCRIPT%" echo *^) +>> "%INSTALL_SCRIPT%" echo echo "Usage: $0 {install|uninstall}" +>> "%INSTALL_SCRIPT%" echo exit 1 +>> "%INSTALL_SCRIPT%" echo ;; +>> "%INSTALL_SCRIPT%" echo esac + +echo Done! Generated %INSTALL_SCRIPT% successfully. +goto :eof + +:AppendFile +set SRC=%1 +set TARGET=%2 +set EOF_MARKER=%3 + +>> "%INSTALL_SCRIPT%" echo cat ^<^< '%EOF_MARKER%' ^> %TARGET% +type "%SRC%" >> "%INSTALL_SCRIPT%" +>> "%INSTALL_SCRIPT%" echo. +>> "%INSTALL_SCRIPT%" echo %EOF_MARKER% +>> "%INSTALL_SCRIPT%" echo. +goto :eof diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..106ba17 --- /dev/null +++ b/build.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# build.sh - Generates the install.sh file dynamically by embedding .sh and .service files + +set -euo pipefail + +INSTALL_SCRIPT="install.sh" + +echo "Building ${INSTALL_SCRIPT}..." + +# Write the header +cat << 'MAIN_EOF' > "$INSTALL_SCRIPT" +#!/bin/bash +# WireGuard Policy Firewall Installer/Uninstaller +# This file is auto-generated. Do not edit directly. Run build.sh or build.bat instead. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root." + exit 1 +fi + +install_policy() { + echo "Installing WireGuard Policy Firewall..." + + echo "Checking dependencies..." + apt-get update -y || true + apt-get install -y jq inotify-tools ipset iptables || true + + echo "Writing scripts to /usr/local/bin/..." + +MAIN_EOF + +# Helper function to append file content inside a heredoc +append_file() { + local file=$1 + local target=$2 + local delimiter="EOF_${file//[-.]/_}" + delimiter=$(echo "$delimiter" | tr '[:lower:]' '[:upper:]') + + echo " cat << '${delimiter}' > ${target}" >> "$INSTALL_SCRIPT" + cat "$file" >> "$INSTALL_SCRIPT" + + # Ensure there is a newline before the EOF marker just in case the file lacks it + echo "" >> "$INSTALL_SCRIPT" + echo "${delimiter}" >> "$INSTALL_SCRIPT" + echo "" >> "$INSTALL_SCRIPT" +} + +# Append all necessary files +append_file "wg-policy-lib.sh" "/usr/local/bin/wg-policy-lib.sh" +append_file "wg-policy-engine.sh" "/usr/local/bin/wg-policy-engine.sh" +append_file "wg-policy-cleanup.sh" "/usr/local/bin/wg-policy-cleanup.sh" +append_file "wg-sync-policy.sh" "/usr/local/bin/wg-sync-policy.sh" +append_file "wg-sync-watch.sh" "/usr/local/bin/wg-sync-watch.sh" +append_file "wg-policy-ctl" "/usr/local/bin/wg-policy-ctl" + +cat << 'MAIN_EOF_MID' >> "$INSTALL_SCRIPT" + echo "Writing systemd units to /etc/systemd/system/..." + +MAIN_EOF_MID + +append_file "wg-policy.service" "/etc/systemd/system/wg-policy.service" +append_file "wg-policy-health.service" "/etc/systemd/system/wg-policy-health.service" +append_file "wg-policy-health.timer" "/etc/systemd/system/wg-policy-health.timer" + +# Write the rest of the installation and uninstallation logic +cat << 'MAIN_EOF_END' >> "$INSTALL_SCRIPT" + chmod +x /usr/local/bin/wg-*.sh /usr/local/bin/wg-policy-ctl + + echo "Reloading systemd daemon..." + systemctl daemon-reload + + echo "Enabling and starting services..." + systemctl enable --now wg-policy.service + systemctl enable --now wg-policy-health.timer + + echo "Installation complete!" + echo "You can check status with: wg-policy-ctl status" +} + +uninstall_policy() { + echo "Uninstalling WireGuard Policy Firewall..." + + echo "Stopping and disabling services..." + systemctl disable --now wg-policy.service wg-policy-health.timer wg-policy-health.service 2>/dev/null || true + + echo "Running cleanup script..." + if [ -x /usr/local/bin/wg-policy-cleanup.sh ]; then + /usr/local/bin/wg-policy-cleanup.sh || true + fi + + echo "Removing systemd units..." + rm -f /etc/systemd/system/wg-policy.service + rm -f /etc/systemd/system/wg-policy-health.service + rm -f /etc/systemd/system/wg-policy-health.timer + systemctl daemon-reload + + echo "Removing scripts from /usr/local/bin/..." + rm -f /usr/local/bin/wg-policy-lib.sh + rm -f /usr/local/bin/wg-sync-policy.sh + rm -f /usr/local/bin/wg-policy-engine.sh + rm -f /usr/local/bin/wg-policy-cleanup.sh + rm -f /usr/local/bin/wg-sync-watch.sh + rm -f /usr/local/bin/wg-policy-ctl + + echo "Uninstallation complete!" +} + +case "${1:-}" in + install) + install_policy + ;; + uninstall) + uninstall_policy + ;; + *) + echo "Usage: $0 {install|uninstall}" + exit 1 + ;; +esac +MAIN_EOF_END + +chmod +x "$INSTALL_SCRIPT" + +echo "Done! Generated ${INSTALL_SCRIPT} successfully." diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..beb06bf --- /dev/null +++ b/install.sh @@ -0,0 +1,1312 @@ +#!/bin/bash +# WireGuard Policy Firewall Installer/Uninstaller + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root." + exit 1 +fi + +install_policy() { + echo "Installing WireGuard Policy Firewall..." + + # Install dependencies + echo "Checking dependencies..." + apt-get update -y || true + apt-get install -y jq inotify-tools ipset iptables || true + + echo "Writing scripts to /usr/local/bin/..." + + cat << 'EOF_WG_POLICY_LIB' > /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 +# ============================================================ + +has_ipset() { + command -v ipset &>/dev/null +} + +ensure_ipset() { + local name="$1" family="$2" + has_ipset || return 0 + if ! ipset list "$name" &>/dev/null; then + ipset create "$name" hash:net,net family "$family" hashsize 1024 maxelem 65536 timeout 0 + log_info "Created ipset: $name (family=$family)" + fi +} + +flush_ipset() { + local name="$1" + has_ipset || return 0 + if ipset list "$name" &>/dev/null; then + ipset flush "$name" + fi +} + +destroy_ipset() { + local name="$1" + has_ipset || return 0 + if ipset list "$name" &>/dev/null; then + ipset destroy "$name" + fi +} + +# ============================================================ +# 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 +} + +detect_default_if() { + local def_if + def_if=$(ip -4 route ls 2>/dev/null | grep default | grep -Po '(?<=dev )(\S+)' | head -1 || true) + if [[ -z "$def_if" ]]; then + echo "eth0" + else + echo "$def_if" + fi +} + +# ============================================================ +# HEALTH CHECK +# ============================================================ + +health_check() { + local status=0 + local report="" + + # 1. Check interface exists + if ip link show "$WG_IF" &>/dev/null; then + report+="[OK] Interface $WG_IF is UP\n" + else + report+="[FAIL] Interface $WG_IF not found\n" + status=1 + fi + + # 2. Check policy.json exists and is valid + if [[ -f "$POLICY_FILE" ]] && jq empty "$POLICY_FILE" 2>/dev/null; then + local client_count + client_count=$(jq '(.clients // {}) | length' "$POLICY_FILE") + report+="[OK] policy.json valid ($client_count clients)\n" + else + report+="[FAIL] policy.json missing or corrupt\n" + status=1 + fi + + # 3. Check 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 +} +EOF_WG_POLICY_LIB + + cat << 'EOF_WG_POLICY_ENGINE' > /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 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 + 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 ! ip link show "$WG_IF" &>/dev/null; then + log_error "Interface $WG_IF is not running. Aborting policy engine." + exit 1 + fi + + if [[ ! -f "$POLICY_FILE" ]]; then + log_error "Policy file not found: $POLICY_FILE" + exit 1 + fi + + if ! jq empty "$POLICY_FILE" 2>/dev/null; then + log_error "policy.json is corrupt" + exit 1 + fi + + # Backup iptables state + backup_iptables + + # Set trap for rollback on failure + trap 'rollback' ERR + + # === DETECT SUBNETS === + local WG_SUBNET WG_SUBNET_V6 LAN_SUBNETS DEF_IF + + WG_SUBNET="$(detect_wg_subnet inet)" + WG_SUBNET_V6="$(detect_wg_subnet inet6)" + LAN_SUBNETS="$(detect_lan_subnets)" + DEF_IF="$(detect_default_if)" + + if [[ -z "$WG_SUBNET" ]]; then + log_warn "Interface $WG_IF has no IPv4, skipping client isolation" + else + log_info "WG IPv4 subnet: $WG_SUBNET" + fi + + if [[ -n "$WG_SUBNET_V6" ]]; then + log_info "WG IPv6 subnet: $WG_SUBNET_V6" + fi + + if [[ -n "$LAN_SUBNETS" ]]; then + log_info "Detected LAN subnets:" + echo "$LAN_SUBNETS" | while read -r s; do log_info " $s"; done + fi + + # === BASE ROUTING & NAT === + # 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 + + # 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 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 2>/dev/null | grep "^-A $CHAIN" | \ + sed "s/-A $CHAIN/-A $CHAIN_BACKUP/" | \ + iptables-restore -c 2>/dev/null || true + log_info "Backed up existing chain to $CHAIN_BACKUP" + fi + + iptables -F "$CHAIN" 2>/dev/null || true + iptables -X "$CHAIN" 2>/dev/null || true + + # === CREATE FRESH CHAIN === + iptables -N "$CHAIN" + + if ! iptables -C FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; then + iptables -I FORWARD 1 -i "$WG_IF" -j "$CHAIN" + fi + + if ! iptables -C FORWARD -o "$WG_IF" -j "$CHAIN" 2>/dev/null; then + iptables -I FORWARD 2 -o "$WG_IF" -j "$CHAIN" + fi + log_info "Chain $CHAIN created and linked to FORWARD (In/Out)" + + # === POPULATE IPSET (hash:net,net for source->target mapping) === + local use_ipset=false + if has_ipset; then + use_ipset=true + log_info "Populating ipsets..." + + ensure_ipset "$IPSET_V4" "inet" + flush_ipset "$IPSET_V4" + + # Check if we need IPv6 ipset + local use_ipv6=false + if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then + use_ipv6=true + ensure_ipset "$IPSET_V6" "inet6" + flush_ipset "$IPSET_V6" + fi + + # Read all access entries and populate ipset (client_ip,target) + jq -r ' + .clients // {} | to_entries[] | + select(.value.access != null and (.value.access | length > 0)) | + .key as $ip | + .value.access[] | + "\($ip) \(.)" + ' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do + [[ -z "$client_ip" || -z "$target" ]] && continue + + if [[ "$target" == *":"* ]]; then + if [[ "$use_ipv6" == true ]]; then + ipset add "$IPSET_V6" "${client_ip},${target}" 2>/dev/null || \ + log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V6" + fi + else + ipset add "$IPSET_V4" "${client_ip},${target}" 2>/dev/null || \ + log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V4" + fi + done + + local v4_count v6_count + v4_count=$(ipset list "$IPSET_V4" 2>/dev/null | grep -c '^[0-9]' || echo 0) + v6_count=$(ipset list "$IPSET_V6" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) + log_info "ipset $IPSET_V4: $v4_count entries, $IPSET_V6: $v6_count entries" + else + log_warn "ipset not installed, falling back to per-rule iptables whitelist" + local use_ipv6=false + if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then + use_ipv6=true + fi + fi + + # === RULE 1: ESTABLISHED,RELATED — allow return traffic === + iptables -A "$CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + + # === RULE 2: WHITELIST (per-client source) === + if [[ "$use_ipset" == true ]]; then + iptables -A "$CHAIN" -m set --match-set "$IPSET_V4" src,dst -j ACCEPT + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -m set --match-set "$IPSET_V6" src,dst -j ACCEPT 2>/dev/null || true + fi + else + jq -r ' + .clients // {} | to_entries[] | + select(.value.access != null and (.value.access | length > 0)) | + .key as $ip | + .value.access[] | + "\($ip) \(.)" + ' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do + [[ -z "$client_ip" || -z "$target" ]] && continue + + if [[ "$client_ip" == *":"* ]]; then + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT 2>/dev/null || true + fi + else + iptables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT + fi + done + fi + + # === RULE 3: ISOLATION — drop NEW connections between WG clients === + if [[ -n "$WG_SUBNET" ]]; then + iptables -A "$CHAIN" \ + -s "$WG_SUBNET" \ + -d "$WG_SUBNET" \ + -m conntrack --ctstate NEW \ + -j DROP + log_info "Client isolation enabled for $WG_SUBNET" + fi + + if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then + ip6tables -A "$CHAIN" \ + -s "$WG_SUBNET_V6" \ + -d "$WG_SUBNET_V6" \ + -m conntrack --ctstate NEW \ + -j DROP 2>/dev/null || true + log_info "Client isolation enabled for IPv6 $WG_SUBNET_V6" + fi + + # === RULE 4: BLOCK LAN — drop from WG subnet to private LAN === + if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then + echo "$LAN_SUBNETS" | while read -r subnet; do + [[ -z "$subnet" ]] && continue + # Skip if LAN subnet exactly matches WG subnet (handled by Rule 3) + [[ "$subnet" == "$WG_SUBNET" ]] && continue + + iptables -A "$CHAIN" -s "$WG_SUBNET" -d "$subnet" -j DROP + log_info "Block: $WG_SUBNET → $subnet" + done + fi + + # IPv6 LAN block (link-local and ULA) + if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then + # Block to link-local (fe80::/10) + ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fe80::/10" -j DROP 2>/dev/null || true + # Block to ULA (fc00::/7) + ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fc00::/7" -j DROP 2>/dev/null || true + log_info "IPv6 LAN block applied (link-local + ULA)" + fi + + # === RULE 5: INTERNET ACCESS (#Internet = true) === + jq -r ' + .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 + + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" \ + -m limit --limit "$LOG_RATE" \ + -j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4 2>/dev/null || true + fi + + # === RULE 7: DEFAULT DROP (internet block by default) === + iptables -A "$CHAIN" -j DROP + + if [[ "$use_ipv6" == true ]]; then + ip6tables -A "$CHAIN" -j DROP 2>/dev/null || true + fi + + # === CLEANUP BACKUP CHAIN (no rollback needed anymore) === + iptables -F "$CHAIN_BACKUP" 2>/dev/null || true + iptables -X "$CHAIN_BACKUP" 2>/dev/null || true + + # 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 "$@" +EOF_WG_POLICY_ENGINE + + cat << 'EOF_WG_POLICY_CLEANUP' > /usr/local/bin/wg-policy-cleanup.sh +#!/bin/bash +# wg-policy-cleanup.sh — Clean removal of all policy artifacts + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/wg-policy-lib.sh" + +main() { + log_info "Starting cleanup..." + + 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 true; do + local line="" + line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$line" ]]; then + iptables -D FORWARD "$line" 2>/dev/null || break + (( removed++ )) + else + break + fi + done + + if (( removed > 0 )); then + log_info "Removed $removed FORWARD references" + fi + + iptables -F "$CHAIN" 2>/dev/null || true + iptables -X "$CHAIN" 2>/dev/null || true + + # Cleanup backup chain too + iptables -F "$CHAIN_BACKUP" 2>/dev/null || true + iptables -X "$CHAIN_BACKUP" 2>/dev/null || true + + # === IPv6 chain cleanup === + if command -v ip6tables &>/dev/null; then + while true; do + local line6="" + line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) + if [[ -n "$line6" ]]; then + ip6tables -D FORWARD "$line6" 2>/dev/null || break + else + break + fi + done + ip6tables -F "$CHAIN" 2>/dev/null || true + ip6tables -X "$CHAIN" 2>/dev/null || true + ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true + ip6tables -X "$CHAIN_BACKUP" 2>/dev/null || true + fi + + # === ipset cleanup === + destroy_ipset "$IPSET_V4" 2>/dev/null || true + destroy_ipset "$IPSET_V6" 2>/dev/null || true + destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true + destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true + + # === Lock cleanup === + rm -f "$LOCK_FILE" 2>/dev/null || true + + log_info "Cleanup complete" +} + +main "$@" +EOF_WG_POLICY_CLEANUP + + cat << 'EOF_WG_SYNC_POLICY' > /usr/local/bin/wg-sync-policy.sh +#!/bin/bash +# wg-sync-policy.sh — Reads wg0.conf, validates, generates policy.json atomically +# Fixed: IP validation, atomic write, proper locking, error handling + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/wg-policy-lib.sh" + +# ============================================================ +# MAIN +# ============================================================ + +main() { + log_info "Starting policy sync..." + + # Validate prerequisites + if [[ ! -f "$WG_CONF" ]]; then + log_error "WireGuard config not found: $WG_CONF" + exit 1 + fi + + if ! command -v jq &>/dev/null; then + log_error "jq is required but not installed" + exit 1 + fi + + # Acquire lock + acquire_lock + trap 'release_lock' EXIT + + # Backup current policy + backup_policy + + # Temporary file for atomic write + local tmp_policy + tmp_policy="$(mktemp /tmp/wg-policy.XXXXXX)" + trap "rm -f \"$tmp_policy\" 2>/dev/null; release_lock" EXIT + + echo '{"clients":{}}' > "$tmp_policy" + + # Parse peers from wg0.conf + # AWK extracts IP and #Access comment per [Peer] block + local parse_errors=0 + + awk ' + BEGIN { RS="\n\\[Peer\\]\n"; FS="\n" } + NR>1 { + ip=""; access=""; internet="false" + for(i=1;i<=NF;i++){ + if($i ~ /^AllowedIPs/) { + split($i,a," = ") + gsub(/ /,"",a[2]) + split(a[2],b,",") + split(b[1],c,"/") + ip=c[1] + } + if($i ~ /^#Access/) { + sub(/^#Access[ \t]*=?[ \t]*/, "", $i) + access=$i + } + if($i ~ /^#Internet/) { + if(tolower($i) ~ /true|yes|1|allow/) { + internet="true" + } + } + } + if(ip!="" && ip!="0.0.0.0" && ip!="::") { + printf "%s|%s|%s\n", ip, access, internet + } + } + ' "$WG_CONF" | while IFS="|" read -r ip access_string internet_flag; do + + # === VALIDATE CLIENT IP === + if ! validate_cidr "$ip"; then + log_warn "Invalid client IP skipped: '$ip'" + (( parse_errors++ )) || true + continue + fi + + # === PARSE AND VALIDATE ACCESS TARGETS === + local ACCESS_JSON="[]" + + if [[ -n "$access_string" ]]; then + # Split by ; and , then validate each entry + local valid_targets=() + local IFS_OLD="$IFS" + IFS=';,' + read -ra targets <<< "$access_string" + IFS="$IFS_OLD" + + for target in "${targets[@]}"; do + # Trim whitespace + target="$(echo "$target" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + [[ -z "$target" ]] && continue + + if validate_cidr "$target"; then + valid_targets+=("$target") + else + log_warn "Invalid access target skipped for $ip: '$target'" + (( parse_errors++ )) || true + fi + done + + if (( ${#valid_targets[@]} > 0 )); then + ACCESS_JSON=$(printf '%s\n' "${valid_targets[@]}" | jq -R . | jq -s .) + fi + fi + + # Write to temp policy + jq --arg ip "$ip" --argjson access "$ACCESS_JSON" --argjson internet "$internet_flag" \ + '.clients[$ip] = {"name": $ip, "access": $access, "internet": $internet}' \ + "$tmp_policy" > "${tmp_policy}.tmp" && mv "${tmp_policy}.tmp" "$tmp_policy" + + done + + # Validate JSON before atomic move + if ! jq empty "$tmp_policy" 2>/dev/null; then + log_error "Generated JSON is invalid, aborting. Check $tmp_policy" + exit 1 + fi + + # Atomic move (same filesystem = atomic rename) + mv -f "$tmp_policy" "$POLICY_FILE" + log_info "policy.json updated successfully" + + if (( parse_errors > 0 )); then + log_warn "$parse_errors validation errors encountered (see warnings above)" + fi + + local client_count + client_count=$(jq '(.clients // {}) | length' "$POLICY_FILE") + log_info "Total clients in policy: $client_count" +} + +main "$@" +EOF_WG_SYNC_POLICY + + cat << 'EOF_WG_SYNC_WATCH' > /usr/local/bin/wg-sync-watch.sh +#!/bin/bash +# wg-sync-watch.sh — Watches wg0.conf for changes with debounce +# Fixed: proper debounce, error isolation, health reporting + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/wg-policy-lib.sh" + +SYNC_SCRIPT="/usr/local/bin/wg-sync-policy.sh" +ENGINE_SCRIPT="/usr/local/bin/wg-policy-engine.sh" +HEALTH_INTERVAL=300 # Health check every 5 minutes +LAST_HEALTH=0 + +# === Validate prerequisites === +if ! command -v inotifywait &>/dev/null; then + log_error "inotifywait not found. Install: apt install inotify-tools" + exit 1 +fi + +if [[ ! -f "$WG_CONF" ]]; then + log_error "WireGuard config not found: $WG_CONF" + exit 1 +fi + +# === Main watcher loop === +log_info "Monitoring $WG_CONF for changes (debounce: ${DEBOUNCE_SEC}s)..." +log_info "Health check interval: ${HEALTH_INTERVAL}s" + +inotifywait -m -e close_write,move,create \ + --format '%e %f' \ + "$(dirname "$WG_CONF")" 2>/dev/null | \ +while read -r events filename; do + + # Only react to wg0.conf changes + [[ "$filename" != "$(basename "$WG_CONF")" ]] && continue + + log_info "Detected change: $events $filename" + + # Debounce: wait until no more events for DEBOUNCE_SEC + while IFS= read -r -t "$DEBOUNCE_SEC" _dummy; do + : # Drain events within debounce window + done + + log_info "Debounce complete, applying changes..." + + # Run sync + if retry "$SYNC_SCRIPT"; then + log_info "Sync successful, running engine..." + + # Run engine with retry + if retry "$ENGINE_SCRIPT"; then + log_info "Policy engine applied successfully" + else + log_error "Policy engine FAILED after retries" + fi + else + log_error "Policy sync FAILED after retries" + fi + + # Periodic health check + local now + now=$(date +%s) + if (( now - LAST_HEALTH >= HEALTH_INTERVAL )); then + LAST_HEALTH=$now + log_info "=== Periodic Health Check ===" + health_check || log_warn "Health check reported issues" + fi + +done +EOF_WG_SYNC_WATCH + + cat << 'EOF_WG_POLICY_CTL' > /usr/local/bin/wg-policy-ctl +#!/bin/bash +# wg-policy-ctl — CLI management tool for WireGuard Policy Firewall + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/wg-policy-lib.sh" + +usage() { + cat < + +Commands: + status Show full health check report + policy Display current policy.json formatted + rules Show current 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 +EOF_WG_POLICY_CTL + + # Write systemd files + echo "Writing systemd units to /etc/systemd/system/..." + + cat << 'EOF_WG_POLICY_SERVICE' > /etc/systemd/system/wg-policy.service +[Unit] +Description=WireGuard Dynamic Policy Firewall Watcher +After=network-online.target wg-quick@wg0.service +Wants=wg-quick@wg0.service network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +ExecStartPre=/usr/local/bin/wg-sync-policy.sh +ExecStart=/usr/local/bin/wg-sync-watch.sh +ExecStopPost=/usr/local/bin/wg-policy-cleanup.sh +Restart=always +RestartSec=10 +User=root +StandardOutput=journal +StandardError=journal +SyslogIdentifier=wg-policy + +# Hardening +ProtectSystem=strict +ReadWritePaths=/etc/wireguard /var/lock /tmp +ProtectHome=yes +NoNewPrivileges=no +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target +EOF_WG_POLICY_SERVICE + + cat << 'EOF_WG_POLICY_HEALTH_SERVICE' > /etc/systemd/system/wg-policy-health.service +[Unit] +Description=WireGuard Policy Health Check + +[Service] +Type=oneshot +ExecStart=/bin/bash -c 'source /usr/local/bin/wg-policy-lib.sh && health_check' +StandardOutput=journal +StandardError=journal +SyslogIdentifier=wg-policy-health +EOF_WG_POLICY_HEALTH_SERVICE + + cat << 'EOF_WG_POLICY_HEALTH_TIMER' > /etc/systemd/system/wg-policy-health.timer +[Unit] +Description=WireGuard Policy Health Check Timer + +[Timer] +OnBootSec=60 +OnUnitActiveSec=300 +AccuracySec=30 + +[Install] +WantedBy=timers.target +EOF_WG_POLICY_HEALTH_TIMER + + # Make executable + chmod +x /usr/local/bin/wg-*.sh /usr/local/bin/wg-policy-ctl + + # Start services + echo "Reloading systemd daemon..." + systemctl daemon-reload + + echo "Enabling and starting services..." + systemctl enable --now wg-policy.service + systemctl enable --now wg-policy-health.timer + + echo "Installation complete!" + echo "You can check status with: wg-policy-ctl status" +} + +uninstall_policy() { + echo "Uninstalling WireGuard Policy Firewall..." + + echo "Stopping and disabling services..." + systemctl disable --now wg-policy.service wg-policy-health.timer wg-policy-health.service 2>/dev/null || true + + echo "Running cleanup script..." + if [ -x /usr/local/bin/wg-policy-cleanup.sh ]; then + /usr/local/bin/wg-policy-cleanup.sh || true + fi + + echo "Removing systemd units..." + rm -f /etc/systemd/system/wg-policy.service + rm -f /etc/systemd/system/wg-policy-health.service + rm -f /etc/systemd/system/wg-policy-health.timer + systemctl daemon-reload + + echo "Removing scripts from /usr/local/bin/..." + rm -f /usr/local/bin/wg-policy-lib.sh + rm -f /usr/local/bin/wg-sync-policy.sh + rm -f /usr/local/bin/wg-policy-engine.sh + rm -f /usr/local/bin/wg-policy-cleanup.sh + rm -f /usr/local/bin/wg-sync-watch.sh + rm -f /usr/local/bin/wg-policy-ctl + + echo "Uninstallation complete!" +} + +case "${1:-}" in + install) + install_policy + ;; + uninstall) + uninstall_policy + ;; + *) + echo "Usage: $0 {install|uninstall}" + exit 1 + ;; +esac diff --git a/wg-policy-engine.sh b/wg-policy-engine.sh index 4db0422..d561b8d 100644 --- a/wg-policy-engine.sh +++ b/wg-policy-engine.sh @@ -111,12 +111,6 @@ main() { 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 @@ -151,9 +145,13 @@ main() { iptables -N "$CHAIN" if ! iptables -C FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; then - iptables -A FORWARD -i "$WG_IF" -j "$CHAIN" + iptables -I FORWARD 1 -i "$WG_IF" -j "$CHAIN" fi - log_info "Chain $CHAIN created and linked to FORWARD" + + if ! iptables -C FORWARD -o "$WG_IF" -j "$CHAIN" 2>/dev/null; then + iptables -I FORWARD 2 -o "$WG_IF" -j "$CHAIN" + fi + log_info "Chain $CHAIN created and linked to FORWARD (In/Out)" # === POPULATE IPSET (hash:net,net for source->target mapping) === local use_ipset=false