From 667c6eac810b253f4b9a36a13d9278916f7ac578 Mon Sep 17 00:00:00 2001 From: kaffa Date: Sat, 7 Feb 2026 09:23:41 +0900 Subject: [PATCH] Fix 12 code review issues (4 MEDIUM + 8 LOW) MEDIUM: - M1: Whitelist direct IP/CIDR additions now persist to direct.txt - M2: get_map_id() uses 5s TTL cache (single bpftool call for all maps) - M3: IPv6 extension header parsing in xdp_ddos.c (hop-by-hop/routing/frag/dst) - M4: Shell injection prevention - sanitize_input() + sys.argv[] for all Python calls LOW: - L1: Remove redundant self.running (uses _stop_event only) - L2: Remove unused config values (rate_limit_after, cooldown_multiplier, retrain_interval) - L3: Thread poll intervals reloaded on SIGHUP - L4: batch_map_operation counts only successfully written entries - L5: Clarify unique_ips_approx comment (per-packet counter) - L6: Document LRU_HASH multi-CPU race condition as acceptable - L7: Download Cloudflare IPv6 ranges in whitelist preset - L8: Fix file handle leak in xdp_country.py list_countries() Also: SIGHUP now preserves EWMA/violation state, daemon skips whitelisted IPs in EWMA/AI escalation, deep copy for default config, IHL validation. Co-Authored-By: Claude Opus 4.6 --- bin/xdp-defense | 86 ++++++++++++++++++++++++++------------ bpf/xdp_ddos.c | 39 ++++++++++++++++- config/xdp-defense.service | 2 +- lib/xdp_common.py | 70 ++++++++++++++++++++++++++----- lib/xdp_country.py | 3 +- lib/xdp_defense_daemon.py | 54 +++++++++++++++--------- lib/xdp_whitelist.py | 31 +++++++++++--- 7 files changed, 218 insertions(+), 67 deletions(-) diff --git a/bin/xdp-defense b/bin/xdp-defense index 328a65a..7785ea5 100755 --- a/bin/xdp-defense +++ b/bin/xdp-defense @@ -2,8 +2,6 @@ # XDP Defense - Unified CLI for XDP Blocker + DDoS Defense # Combines CIDR/country/whitelist blocking with rate limiting + AI detection # Uses libxdp dispatcher for chaining two XDP programs -set -e - PROJ_DIR="/opt/xdp-defense" BPF_DIR="$PROJ_DIR/bpf" LIB_DIR="$PROJ_DIR/lib" @@ -32,6 +30,16 @@ log_ok() { echo -e "${GREEN}[OK]${NC} $1"; logger -t xdp-defense "OK: $1" 2>/d log_err() { echo -e "${RED}[ERROR]${NC} $1" >&2; logger -t xdp-defense -p user.err "ERROR: $1" 2>/dev/null || true; } log_info() { echo -e "${CYAN}[INFO]${NC} $1"; logger -t xdp-defense "INFO: $1" 2>/dev/null || true; } +# Sanitize input for safe embedding in Python strings (reject dangerous chars) +sanitize_input() { + local val="$1" + if [[ "$val" =~ [\'\"\;\$\`\\] ]]; then + log_err "Invalid characters in input: $val" + return 1 + fi + echo "$val" +} + get_iface() { if [ -f "$CONFIG_FILE" ]; then local iface @@ -265,7 +273,8 @@ print(f'Blocked IPs: {len(v4) + len(v6)}') # ==================== Blocker Commands ==================== cmd_blocker_add() { - local cidr=$1 + local cidr + cidr=$(sanitize_input "$1") || exit 1 [ -z "$cidr" ] && { log_err "Usage: xdp-defense blocker add "; exit 1; } if [[ "$cidr" == *":"* ]]; then @@ -274,7 +283,7 @@ cmd_blocker_add() { [ -z "$map_id" ] && { log_err "IPv6 map not found. Is XDP loaded?"; exit 1; } local key_hex - key_hex=$(python3 -c "from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6('$cidr'))" 2>/dev/null) + key_hex=$(python3 -c "import sys; from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6(sys.argv[1]))" "$cidr" 2>/dev/null) [ -z "$key_hex" ] && { log_err "Invalid IPv6 CIDR: $cidr"; exit 1; } bpftool map update id "$map_id" key hex $key_hex value hex 01 00 00 00 00 00 00 00 2>/dev/null @@ -312,7 +321,8 @@ cmd_blocker_add() { } cmd_blocker_del() { - local cidr=$1 + local cidr + cidr=$(sanitize_input "$1") || exit 1 [ -z "$cidr" ] && { log_err "Usage: xdp-defense blocker del "; exit 1; } if [[ "$cidr" == *":"* ]]; then @@ -321,7 +331,7 @@ cmd_blocker_del() { [ -z "$map_id" ] && { log_err "IPv6 map not found"; exit 1; } local key_hex - key_hex=$(python3 -c "from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6('$cidr'))" 2>/dev/null) + key_hex=$(python3 -c "import sys; from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6(sys.argv[1]))" "$cidr" 2>/dev/null) [ -z "$key_hex" ] && { log_err "Invalid IPv6 CIDR: $cidr"; exit 1; } bpftool map delete id "$map_id" key hex $key_hex 2>/dev/null && log_ok "Removed (v6): $cidr" @@ -442,7 +452,8 @@ cmd_country_list() { # ==================== Whitelist Commands ==================== cmd_whitelist_add() { - local name=$1 + local name + name=$(sanitize_input "$1") || exit 1 [ -z "$name" ] && { log_err "Usage: xdp-defense whitelist add "; exit 1; } # Direct IP/CIDR: contains a dot (IPv4) or colon (IPv6) with digits @@ -450,10 +461,10 @@ cmd_whitelist_add() { local map_name key_hex if [[ "$name" == *":"* ]]; then map_name="whitelist_v6" - key_hex=$(python3 -c "from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6('$name'))" 2>/dev/null) + key_hex=$(python3 -c "import sys; from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6(sys.argv[1]))" "$name" 2>/dev/null) else map_name="whitelist_v4" - key_hex=$(python3 -c "from ${COMMON_PY} import cidr_to_key; print(cidr_to_key('$name'))" 2>/dev/null) + key_hex=$(python3 -c "import sys; from ${COMMON_PY} import cidr_to_key; print(cidr_to_key(sys.argv[1]))" "$name" 2>/dev/null) fi [ -z "$key_hex" ] && { log_err "Invalid CIDR: $name"; exit 1; } @@ -462,6 +473,12 @@ cmd_whitelist_add() { [ -z "$map_id" ] && { log_err "$map_name map not found. Is XDP loaded?"; exit 1; } bpftool map update id "$map_id" key hex $key_hex value hex 01 00 00 00 00 00 00 00 2>/dev/null + + # Persist to file for restore on reload + local direct_file="/etc/xdp-blocker/whitelist/direct.txt" + mkdir -p "$(dirname "$direct_file")" + grep -qxF "$name" "$direct_file" 2>/dev/null || echo "$name" >> "$direct_file" + log_ok "Whitelisted: $name" return fi @@ -470,7 +487,8 @@ cmd_whitelist_add() { } cmd_whitelist_del() { - local name=$1 + local name + name=$(sanitize_input "$1") || exit 1 [ -z "$name" ] && { log_err "Usage: xdp-defense whitelist del "; exit 1; } # Direct IP/CIDR @@ -478,10 +496,10 @@ cmd_whitelist_del() { local map_name key_hex if [[ "$name" == *":"* ]]; then map_name="whitelist_v6" - key_hex=$(python3 -c "from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6('$name'))" 2>/dev/null) + key_hex=$(python3 -c "import sys; from ${COMMON_PY} import cidr_to_key_v6; print(cidr_to_key_v6(sys.argv[1]))" "$name" 2>/dev/null) else map_name="whitelist_v4" - key_hex=$(python3 -c "from ${COMMON_PY} import cidr_to_key; print(cidr_to_key('$name'))" 2>/dev/null) + key_hex=$(python3 -c "import sys; from ${COMMON_PY} import cidr_to_key; print(cidr_to_key(sys.argv[1]))" "$name" 2>/dev/null) fi [ -z "$key_hex" ] && { log_err "Invalid CIDR: $name"; exit 1; } @@ -490,6 +508,15 @@ cmd_whitelist_del() { [ -z "$map_id" ] && { log_err "$map_name map not found"; exit 1; } bpftool map delete id "$map_id" key hex $key_hex 2>/dev/null && log_ok "Removed from whitelist: $name" + + # Remove from persistence file + local direct_file="/etc/xdp-blocker/whitelist/direct.txt" + if [ -f "$direct_file" ]; then + local tmpfile="${direct_file}.tmp.$$" + { grep -vxF "$name" "$direct_file" || true; } > "$tmpfile" 2>/dev/null + mv "$tmpfile" "$direct_file" + fi + return fi @@ -516,10 +543,12 @@ for i, label in enumerate(labels): cmd_ddos_top() { local n=${1:-10} + [[ "$n" =~ ^[0-9]+$ ]] || n=10 echo -e "${BOLD}=== Top $n IPs by Packet Count ===${NC}" python3 -c " +import sys from ${COMMON_PY} import dump_rate_counters -entries = dump_rate_counters('rate_counter_v4', $n) +entries = dump_rate_counters('rate_counter_v4', int(sys.argv[1])) if not entries: print(' (empty)') else: @@ -528,13 +557,13 @@ else: for ip, pkts, bts, _ in entries: print(f' {ip:>18} {pkts:>10} {bts:>12}') -entries6 = dump_rate_counters('rate_counter_v6', $n) +entries6 = dump_rate_counters('rate_counter_v6', int(sys.argv[1])) if entries6: print() print(' IPv6:') for ip, pkts, bts, _ in entries6: print(f' {ip:>42} {pkts:>10} {bts:>12}') -" 2>/dev/null || log_err "Cannot read rate counters. Is XDP loaded?" +" "$n" 2>/dev/null || log_err "Cannot read rate counters. Is XDP loaded?" } cmd_ddos_blocked() { @@ -569,11 +598,13 @@ else: } cmd_ddos_block() { - local ip=$1 + local ip + ip=$(sanitize_input "$1") || exit 1 local duration=${2:-0} [ -z "$ip" ] && { log_err "Usage: xdp-defense ddos block [duration_sec]"; exit 1; } + [[ "$duration" =~ ^[0-9]+$ ]] || { log_err "Invalid duration: $duration"; exit 1; } - python3 -c "from ${COMMON_PY} import block_ip; block_ip('$ip', $duration)" 2>/dev/null || \ + python3 -c "import sys; from ${COMMON_PY} import block_ip; block_ip(sys.argv[1], int(sys.argv[2]))" "$ip" "$duration" 2>/dev/null || \ { log_err "Failed to block $ip"; exit 1; } if [ "$duration" -gt 0 ] 2>/dev/null; then @@ -584,10 +615,11 @@ cmd_ddos_block() { } cmd_ddos_unblock() { - local ip=$1 + local ip + ip=$(sanitize_input "$1") || exit 1 [ -z "$ip" ] && { log_err "Usage: xdp-defense ddos unblock "; exit 1; } - python3 -c "from ${COMMON_PY} import unblock_ip; unblock_ip('$ip')" 2>/dev/null || \ + python3 -c "import sys; from ${COMMON_PY} import unblock_ip; unblock_ip(sys.argv[1])" "$ip" 2>/dev/null || \ { log_err "Failed to unblock $ip"; exit 1; } log_ok "Unblocked $ip" } @@ -637,25 +669,24 @@ if profiles: local key=$2 local value=$3 [ -z "$key" ] || [ -z "$value" ] && { log_err "Usage: xdp-defense ddos config set "; exit 1; } + [[ "$key" =~ ^(pps|bps|window)$ ]] || { log_err "Unknown key: $key (must be pps|bps|window)"; exit 1; } + [[ "$value" =~ ^[0-9]+$ ]] || { log_err "Invalid value: $value (must be integer)"; exit 1; } python3 -c " +import sys from ${COMMON_PY} import read_rate_config, write_rate_config cfg = read_rate_config() if not cfg: cfg = {'pps_threshold': 1000, 'bps_threshold': 0, 'window_ns': 1000000000} -key = '$key' -value = int('$value') +key, value = sys.argv[1], int(sys.argv[2]) if key == 'pps': cfg['pps_threshold'] = value elif key == 'bps': cfg['bps_threshold'] = value elif key == 'window': cfg['window_ns'] = value * 1000000000 -else: - print(f'Unknown key: {key}') - raise SystemExit(1) write_rate_config(cfg['pps_threshold'], cfg['bps_threshold'], cfg['window_ns']) -" 2>/dev/null || { log_err "Failed to set config"; exit 1; } +" "$key" "$value" 2>/dev/null || { log_err "Failed to set config"; exit 1; } log_ok "Set $key=$value" ;; *) @@ -773,7 +804,8 @@ cmd_ai_retrain() { # ==================== GeoIP ==================== cmd_geoip() { - local ip=$1 + local ip + ip=$(sanitize_input "$1") || exit 1 [ -z "$ip" ] && { log_err "Usage: xdp-defense geoip "; exit 1; } echo "=== GeoIP Lookup: $ip ===" @@ -829,7 +861,7 @@ cmd_geoip() { ddos_map_id=$(get_map_id blocked_ips_v4) if [ -n "$ddos_map_id" ]; then local ddos_key_hex - ddos_key_hex=$(python3 -c "from ${COMMON_PY} import ip_to_hex_key; print(ip_to_hex_key('$ip'))" 2>/dev/null) + ddos_key_hex=$(python3 -c "import sys; from ${COMMON_PY} import ip_to_hex_key; print(ip_to_hex_key(sys.argv[1]))" "$ip" 2>/dev/null) if [ -n "$ddos_key_hex" ] && bpftool map lookup id "$ddos_map_id" key hex $ddos_key_hex 2>/dev/null | grep -q "value"; then echo -e "DDoS: ${RED}BLOCKED${NC}" else diff --git a/bpf/xdp_ddos.c b/bpf/xdp_ddos.c index 7708e27..2c10f07 100644 --- a/bpf/xdp_ddos.c +++ b/bpf/xdp_ddos.c @@ -61,7 +61,7 @@ struct traffic_features { __u64 udp_count; __u64 icmp_count; __u64 other_proto_count; - __u64 unique_ips_approx; // approximate via counter + __u64 unique_ips_approx; // per-packet counter (not truly unique, used as relative indicator) __u64 small_pkt_count; // packets < 100 bytes __u64 large_pkt_count; // packets > 1400 bytes }; @@ -218,6 +218,9 @@ static __always_inline int check_blocked_v6(struct in6_addr *ip, __u64 now) { } // Rate check for IPv4: returns 1 if rate exceeded +// Note: LRU_HASH lookups on multi-CPU are racy (no per-entry lock), so counters +// may be slightly inaccurate under high concurrency. This is acceptable for rate +// limiting where approximate enforcement is sufficient. static __always_inline int rate_check_v4(__u32 ip, __u64 now, __u64 pkt_len) { __u32 cfg_key = 0; struct rate_cfg *cfg = bpf_map_lookup_elem(&rate_config, &cfg_key); @@ -349,6 +352,12 @@ int xdp_ddos(struct xdp_md *ctx) { __u8 proto = iph->protocol; __u8 tcp_flags = 0; + // Validate IHL (minimum 5 = 20 bytes) + if (iph->ihl < 5) { + inc_stat(4); + return XDP_PASS; + } + // Extract TCP flags if applicable if (proto == IPPROTO_TCP) { struct tcphdr *tcph = l3_hdr + (iph->ihl * 4); @@ -393,9 +402,35 @@ int xdp_ddos(struct xdp_md *ctx) { struct in6_addr saddr = ip6h->saddr; __u8 proto = ip6h->nexthdr; __u8 tcp_flags = 0; + void *next_hdr = (void *)(ip6h + 1); + + // Skip known IPv6 extension headers (up to 4 to stay within verifier limits) + #pragma unroll + for (int i = 0; i < 4; i++) { + if (proto != IPPROTO_HOPOPTS && proto != IPPROTO_ROUTING && + proto != IPPROTO_DSTOPTS && proto != IPPROTO_FRAGMENT) + break; + if (proto == IPPROTO_FRAGMENT) { + // Fragment header is fixed 8 bytes + if (next_hdr + 8 > data_end) + break; + proto = *(__u8 *)next_hdr; + next_hdr += 8; + } else { + // Other extension headers: length in 2nd byte (units of 8 octets, +8) + if (next_hdr + 2 > data_end) + break; + __u8 ext_len = *((__u8 *)next_hdr + 1); + __u32 hdr_len = (((__u32)ext_len) + 1) * 8; + if (next_hdr + hdr_len > data_end) + break; + proto = *(__u8 *)next_hdr; + next_hdr += hdr_len; + } + } if (proto == IPPROTO_TCP) { - struct tcphdr *tcph = (void *)(ip6h + 1); + struct tcphdr *tcph = next_hdr; if ((void *)(tcph + 1) <= data_end) { tcp_flags = ((__u8 *)tcph)[13]; } diff --git a/config/xdp-defense.service b/config/xdp-defense.service index a67a287..c224d46 100644 --- a/config/xdp-defense.service +++ b/config/xdp-defense.service @@ -15,7 +15,7 @@ RestartSec=5 # Security hardening ProtectSystem=strict -ReadWritePaths=/var/lib/xdp-defense /etc/xdp-defense /etc/xdp-blocker /sys/fs/bpf +ReadWritePaths=/var/lib/xdp-defense /etc/xdp-defense /etc/xdp-blocker /sys/fs/bpf /tmp ProtectHome=true NoNewPrivileges=false CapabilityBoundingSet=CAP_NET_ADMIN CAP_BPF CAP_SYS_ADMIN CAP_PERFMON diff --git a/lib/xdp_common.py b/lib/xdp_common.py index 3999d08..e423f54 100644 --- a/lib/xdp_common.py +++ b/lib/xdp_common.py @@ -32,8 +32,18 @@ if _syslog_handler: # ==================== BPF Map Helpers ==================== +_map_cache = {} # {map_name: (map_id, timestamp)} +_MAP_CACHE_TTL = 5.0 # seconds + def get_map_id(map_name): - """Get BPF map ID by name.""" + """Get BPF map ID by name (cached with 5s TTL).""" + import time as _time + now = _time.monotonic() + + cached = _map_cache.get(map_name) + if cached and (now - cached[1]) < _MAP_CACHE_TTL: + return cached[0] + result = subprocess.run( ["bpftool", "map", "show", "-j"], capture_output=True, text=True @@ -42,12 +52,15 @@ def get_map_id(map_name): return None try: maps = json.loads(result.stdout) + # Update cache for all maps found for m in maps: - if m.get("name") == map_name: - return m.get("id") + name = m.get("name") + if name: + _map_cache[name] = (m.get("id"), now) except Exception: pass - return None + cached = _map_cache.get(map_name) + return cached[0] if cached else None # ==================== CIDR / IPv4 Helpers (from blocker) ==================== @@ -130,10 +143,12 @@ def batch_map_operation(map_id, cidrs, operation="update", value_hex="01 00 00 0 """ total = len(cidrs) processed = 0 + written = 0 for i in range(0, total, batch_size): batch = cidrs[i:i + batch_size] batch_file = None + batch_written = 0 try: with tempfile.NamedTemporaryFile(mode='w', suffix='.batch', delete=False) as f: @@ -145,6 +160,7 @@ def batch_map_operation(map_id, cidrs, operation="update", value_hex="01 00 00 0 f.write(f"map update id {map_id} key hex {key_hex} value hex {value_hex}\n") else: f.write(f"map delete id {map_id} key hex {key_hex}\n") + batch_written += 1 except (ValueError, Exception): continue @@ -157,13 +173,44 @@ def batch_map_operation(map_id, cidrs, operation="update", value_hex="01 00 00 0 os.unlink(batch_file) processed += len(batch) + written += batch_written pct = processed * 100 // total if total > 0 else 100 print(f"\r Progress: {processed}/{total} ({pct}%)", end="", flush=True) print() proto = "v6" if ipv6 else "v4" - audit_log.info(f"batch {operation} {proto}: {processed} entries on map {map_id}") - return processed + audit_log.info(f"batch {operation} {proto}: {written} entries on map {map_id}") + return written + + +# ==================== Whitelist Check (for daemon) ==================== + +def is_whitelisted(ip_str): + """Check if an IP is in the BPF whitelist maps. + Returns True if whitelisted, False otherwise. + """ + try: + addr = ipaddress.ip_address(ip_str) + except ValueError: + return False + + if isinstance(addr, ipaddress.IPv6Address): + map_name = "whitelist_v6" + key_hex = cidr_to_key_v6(f"{ip_str}/128") + else: + map_name = "whitelist_v4" + key_hex = cidr_to_key(f"{ip_str}/32") + + map_id = get_map_id(map_name) + if map_id is None: + return False + + result = subprocess.run( + ["bpftool", "map", "lookup", "id", str(map_id), + "key", "hex"] + key_hex.split(), + capture_output=True, text=True + ) + return result.returncode == 0 # ==================== IP Encoding Helpers (from ddos) ==================== @@ -360,16 +407,17 @@ def block_ip(ip_str, duration_sec=0): key_hex = ip_to_hex_key(ip_str) + # Use CLOCK_BOOTTIME (matches BPF ktime_get_ns) + with open('/proc/uptime', 'r') as f: + uptime_sec = float(f.read().split()[0]) + now_ns = int(uptime_sec * 1_000_000_000) + if duration_sec > 0: - with open('/proc/uptime', 'r') as f: - uptime_sec = float(f.read().split()[0]) - now_ns = int(uptime_sec * 1_000_000_000) expire_ns = now_ns + (duration_sec * 1_000_000_000) else: expire_ns = 0 - now_ns_val = 0 - raw = struct.pack(' %s", ip_str, level) @@ -620,7 +638,7 @@ class DDoSDaemon: def _profile_thread(self): """Check time-of-day and switch rate profiles.""" - while self.running: + while not self._stop_event.is_set(): try: self.profile_manager.check_and_apply() except Exception as e: @@ -631,7 +649,7 @@ class DDoSDaemon: """Periodically clean up expired blocked IPs and stale violations.""" from xdp_common import dump_blocked_ips, unblock_ip - while self.running: + while not self._stop_event.is_set(): try: with open('/proc/uptime') as f: now_ns = int(float(f.read().split()[0]) * 1_000_000_000) @@ -667,7 +685,6 @@ class DDoSDaemon: self._ensure_single_instance() self._write_pid() - self.running = True threads = [ threading.Thread(target=self._ewma_thread, name='ewma', daemon=True), @@ -683,13 +700,12 @@ class DDoSDaemon: log.info("Daemon running (PID %d)", os.getpid()) try: - while self.running: + while not self._stop_event.is_set(): self._stop_event.wait(1) except KeyboardInterrupt: pass log.info("Shutting down...") - self.running = False self._stop_event.set() for t in threads: diff --git a/lib/xdp_whitelist.py b/lib/xdp_whitelist.py index 347b86d..6abeb58 100755 --- a/lib/xdp_whitelist.py +++ b/lib/xdp_whitelist.py @@ -35,7 +35,7 @@ PRESETS = { } def download_cloudflare(): - """Download Cloudflare IP ranges""" + """Download Cloudflare IP ranges (IPv4 + IPv6)""" cidrs = [] try: req = urllib.request.Request( @@ -43,10 +43,23 @@ def download_cloudflare(): headers={"User-Agent": "xdp-whitelist/1.0"} ) with urllib.request.urlopen(req) as r: - cidrs.extend(r.read().decode().strip().split('\n')) - print(f" Downloaded {len(cidrs)} IPv4 ranges") + v4 = r.read().decode().strip().split('\n') + cidrs.extend(v4) + print(f" Downloaded {len(v4)} IPv4 ranges") except Exception as e: print(f" [WARN] Failed to download IPv4: {e}") + + try: + req = urllib.request.Request( + PRESETS["cloudflare"]["v6"], + headers={"User-Agent": "xdp-whitelist/1.0"} + ) + with urllib.request.urlopen(req) as r: + v6 = r.read().decode().strip().split('\n') + cidrs.extend(v6) + print(f" Downloaded {len(v6)} IPv6 ranges") + except Exception as e: + print(f" [WARN] Failed to download IPv6: {e}") return cidrs def download_aws(): @@ -103,7 +116,7 @@ def add_whitelist(name, cidrs=None): if cidrs is None and wl_file.exists(): with open(wl_file) as f: - cidrs = [line.strip() for line in f if line.strip() and ':' not in line] + cidrs = [line.strip() for line in f if line.strip()] if cidrs: print(f"[INFO] Using cached {name} ({len(cidrs)} CIDRs)") @@ -200,9 +213,15 @@ def list_whitelist(): for wl_file in sorted(files): name = wl_file.stem - count = sum(1 for line in open(wl_file) if line.strip() and ':' not in line) + with open(wl_file) as f: + cidrs = [line.strip() for line in f if line.strip()] + v4_count = sum(1 for c in cidrs if ':' not in c) + v6_count = len(cidrs) - v4_count desc = PRESETS.get(name, {}).get("desc", "Custom") - print(f" {name}: {count} CIDRs ({desc})") + if v6_count > 0: + print(f" {name}: {v4_count} v4 + {v6_count} v6 CIDRs ({desc})") + else: + print(f" {name}: {v4_count} CIDRs ({desc})") def show_presets(): """Show available presets"""