initial commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[Interface]
|
||||
Address = 10.172.20.1/24
|
||||
SaveConfig = true
|
||||
PreUp =
|
||||
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;
|
||||
PreDown =
|
||||
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE;
|
||||
ListenPort = 51820
|
||||
PrivateKey = 8IUqNz0TMSXJDaczi4sLXoaWTaSysql+K+bEC6V7iFo=
|
||||
|
||||
[Peer]
|
||||
PublicKey = 6pMcvsgQm6PgffzhIR9do0hODaivajPBwNWB5MP1hm8=
|
||||
PresharedKey = gsEfrgTfq95jjUIP5KlnapDkCA6APilTWlIWCn6Fp9Q=
|
||||
AllowedIPs = 10.172.20.2/32
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/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 "$@"
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/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 "$@"
|
||||
@@ -0,0 +1,9 @@
|
||||
[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
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=WireGuard Policy Health Check Timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=60
|
||||
OnUnitActiveSec=300
|
||||
AccuracySec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[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
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
# wg-sync-policy.sh — Reads wg0.conf, validates, generates policy.json atomically
|
||||
# Fixed: IP validation, atomic write, proper locking, error handling
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/wg-policy-lib.sh"
|
||||
|
||||
# ============================================================
|
||||
# MAIN
|
||||
# ============================================================
|
||||
|
||||
main() {
|
||||
log_info "Starting policy sync..."
|
||||
|
||||
# Validate prerequisites
|
||||
if [[ ! -f "$WG_CONF" ]]; then
|
||||
log_error "WireGuard config not found: $WG_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
log_error "jq is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Acquire lock
|
||||
acquire_lock
|
||||
trap 'release_lock' EXIT
|
||||
|
||||
# Backup current policy
|
||||
backup_policy
|
||||
|
||||
# Temporary file for atomic write
|
||||
local tmp_policy
|
||||
tmp_policy="$(mktemp /tmp/wg-policy.XXXXXX)"
|
||||
trap 'rm -f "$tmp_policy" 2>/dev/null; release_lock' EXIT
|
||||
|
||||
echo '{"clients":{}}' > "$tmp_policy"
|
||||
|
||||
# Parse peers from wg0.conf
|
||||
# AWK extracts IP and #Access comment per [Peer] block
|
||||
local parse_errors=0
|
||||
|
||||
awk '
|
||||
BEGIN { RS="\n\\[Peer\\]\n"; FS="\n" }
|
||||
NR>1 {
|
||||
ip=""; access=""
|
||||
for(i=1;i<=NF;i++){
|
||||
if($i ~ /^AllowedIPs/) {
|
||||
split($i,a," = ")
|
||||
gsub(/ /,"",a[2])
|
||||
split(a[2],b,",")
|
||||
split(b[1],c,"/")
|
||||
ip=c[1]
|
||||
}
|
||||
if($i ~ /^#Access/) {
|
||||
sub(/^#Access[ \t]+/, "", $i)
|
||||
access=$i
|
||||
}
|
||||
}
|
||||
if(ip!="" && ip!="0.0.0.0" && ip!="::") {
|
||||
printf "%s|%s\n", ip, access
|
||||
}
|
||||
}
|
||||
' "$WG_CONF" | while IFS="|" read -r ip access_string; do
|
||||
|
||||
# === VALIDATE CLIENT IP ===
|
||||
if ! validate_cidr "$ip"; then
|
||||
log_warn "Invalid client IP skipped: '$ip'"
|
||||
(( parse_errors++ )) || true
|
||||
continue
|
||||
fi
|
||||
|
||||
# === PARSE AND VALIDATE ACCESS TARGETS ===
|
||||
local ACCESS_JSON="[]"
|
||||
|
||||
if [[ -n "$access_string" ]]; then
|
||||
# Split by ; and , then validate each entry
|
||||
local valid_targets=()
|
||||
local IFS_OLD="$IFS"
|
||||
IFS=';,'
|
||||
read -ra targets <<< "$access_string"
|
||||
IFS="$IFS_OLD"
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
# Trim whitespace
|
||||
target="$(echo "$target" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
|
||||
[[ -z "$target" ]] && continue
|
||||
|
||||
if validate_cidr "$target"; then
|
||||
valid_targets+=("$target")
|
||||
else
|
||||
log_warn "Invalid access target skipped for $ip: '$target'"
|
||||
(( parse_errors++ )) || true
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#valid_targets[@]} > 0 )); then
|
||||
ACCESS_JSON=$(printf '%s\n' "${valid_targets[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write to temp policy
|
||||
jq --arg ip "$ip" --argjson access "$ACCESS_JSON" \
|
||||
'.clients[$ip] = {"name": $ip, "access": $access}' \
|
||||
"$tmp_policy" > "${tmp_policy}.tmp" && mv "${tmp_policy}.tmp" "$tmp_policy"
|
||||
|
||||
done
|
||||
|
||||
# Validate JSON before atomic move
|
||||
if ! jq empty "$tmp_policy" 2>/dev/null; then
|
||||
log_error "Generated JSON is invalid, aborting. Check $tmp_policy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Atomic move (same filesystem = atomic rename)
|
||||
mv -f "$tmp_policy" "$POLICY_FILE"
|
||||
log_info "policy.json updated successfully"
|
||||
|
||||
if (( parse_errors > 0 )); then
|
||||
log_warn "$parse_errors validation errors encountered (see warnings above)"
|
||||
fi
|
||||
|
||||
local client_count
|
||||
client_count=$(jq '(.clients // {}) | length' "$POLICY_FILE")
|
||||
log_info "Total clients in policy: $client_count"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user