commit 1e12bf4a1b9e89cfeb2acad25f98a6a4ff3acd28 Author: Gino Ben Salah Date: Mon Mar 16 22:25:49 2026 +0000 Upload diff --git a/HorizonBench.sh b/HorizonBench.sh new file mode 100644 index 0000000..ca11e97 --- /dev/null +++ b/HorizonBench.sh @@ -0,0 +1,1553 @@ +#!/usr/bin/env bash +# ============================================================================= +# mtu_test.sh — MTU diagnostic & load test (AlmaLinux / Debian) v2.1 +# +# READ-ONLY / NON-DESTRUCTIVE: This script makes NO changes to the system. +# It reads kernel state, sends ICMP/TCP probes, and writes a log to /tmp. +# All temp files (iperf3 output, ping logs) are cleaned up via trap on exit. +# +# Load test (Section 6): +# - Starts iperf3 server on 127.0.0.1:15201 (loopback, no remote needed) +# - Runs 4x parallel TCP streams MSS 1460 for LOAD_DURATION seconds +# - Concurrently fires ICMP MTU step-down probes (DF-bit) at TARGET +# so you see which sizes fail *under real TCP load* vs idle +# - Reports retransmits + RX/TX error deltas +# - Falls back to flood-ping if iperf3 is not installed +# +# Usage: sudo ./mtu_test.sh [TARGET_IP] [INTERFACE] [--no-load] +# +# TARGET_IP IP or hostname to probe (default: 8.8.8.8) +# INTERFACE Network interface to inspect (default: auto-detected) +# --no-load Skip the load test entirely (zero extra traffic) +# +# Examples: +# sudo ./mtu_test.sh +# sudo ./mtu_test.sh 1.2.3.4 eth0 +# sudo ./mtu_test.sh 1.2.3.4 eth0 --no-load +# ============================================================================= + +# Do NOT use set -e / set -euo pipefail — this is a diagnostic script and +# individual test failures (e.g. ping returning non-zero) must not abort the +# run. Each command handles its own errors explicitly. +set -uo pipefail + +# ── Colours ────────────────────────────────────────────────────────────────── +readonly RED='\033[0;31m' +readonly YEL='\033[1;33m' +readonly GRN='\033[0;32m' +readonly CYN='\033[0;36m' +readonly BLD='\033[1m' +readonly RST='\033[0m' + +# ── Argument parsing ────────────────────────────────────────────────────────── +TARGET="8.8.8.8" +IFACE="" +NO_LOAD=0 +EXPECTED_MTU=0 # 0 = auto (assume 1500), >0 = user-specified tunnel MTU +_TARGET_SET="" +_IFACE_SET="" + +_prev_arg="" +for arg in "$@"; do + case "$arg" in + --no-load) NO_LOAD=1 ;; + --expected-mtu) + _prev_arg="--expected-mtu" ;; + --expected-mtu=*) + EXPECTED_MTU="${arg#--expected-mtu=}" + if ! [[ "$EXPECTED_MTU" =~ ^[0-9]+$ ]] || [[ $EXPECTED_MTU -lt 576 ]] || [[ $EXPECTED_MTU -gt 9000 ]]; then + echo -e "${RED} --expected-mtu must be a number between 576 and 9000${RST}"; exit 1 + fi ;; + --*) + echo -e "${RED}Unknown option: $arg${RST}" + echo -e " Usage: $0 [TARGET_IP] [INTERFACE] [--no-load] [--expected-mtu N]" + exit 1 ;; + *) + if [[ "$_prev_arg" == "--expected-mtu" ]]; then + EXPECTED_MTU="$arg" + if ! [[ "$EXPECTED_MTU" =~ ^[0-9]+$ ]] || [[ $EXPECTED_MTU -lt 576 ]] || [[ $EXPECTED_MTU -gt 9000 ]]; then + echo -e "${RED} --expected-mtu must be a number between 576 and 9000${RST}"; exit 1 + fi + _prev_arg="" + elif [[ -z "$_TARGET_SET" ]]; then + TARGET="$arg"; _TARGET_SET=1 + elif [[ -z "$_IFACE_SET" ]]; then + IFACE="$arg"; _IFACE_SET=1 + fi + ;; + esac + [[ "$arg" != "--expected-mtu" ]] && _prev_arg="" +done + +# ── Runtime state ───────────────────────────────────────────────────────────── +LOG_FILE="/tmp/mtu_test_$(date +%Y%m%d_%H%M%S).log" +LOAD_DURATION=15 +LOAD_PARALLEL=4 +TMPDIR_PINGS="/tmp/mtu_pings_$$" +HAS_IPERF3=0 +HAS_ETHTOOL=0 +HAS_TC=0 +HAS_BIRD=0 +ISSUES_FOUND=0 + +# Role detection results (set by detect_role) +ROLE="unknown" # vps | wg-router | bird-router | wg-bird-router | generic +WG_IFACES=() # detected WireGuard interfaces +WG_MTU=0 # MTU of first WG interface found +WG_PEERS=0 # total WireGuard peer count +WG_PUBSUBNETS=() # routed public subnets found in wg allowed-ips +BIRD_RUNNING=0 # 1 if BIRD2 daemon is active +BIRD_PROTOCOLS=() # active BGP/OSPF protocol names +IP_FORWARD=0 # kernel ip_forward value + +# ── Cleanup trap — only removes files this script created ──────────────────── +cleanup() { + rm -rf "$TMPDIR_PINGS" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# ── Logging helpers ─────────────────────────────────────────────────────────── +log() { echo -e "$*" | tee -a "$LOG_FILE"; } +pass() { log "${GRN} [PASS]${RST} $*"; } +warn() { log "${YEL} [WARN]${RST} $*"; ISSUES_FOUND=$(( ISSUES_FOUND + 1 )); } +fail() { log "${RED} [FAIL]${RST} $*"; ISSUES_FOUND=$(( ISSUES_FOUND + 1 )); } +info() { log "${CYN} [INFO]${RST} $*"; } +skip() { log "${YEL} [SKIP]${RST} $*"; } +sect() { + log "" + log "${BLD}${CYN}══════════════════════════════════════════════${RST}" + log "${BLD}${CYN} $*${RST}" + log "${BLD}${CYN}══════════════════════════════════════════════${RST}" +} + +# Safe read-only command wrapper — never aborts on failure +safe_read() { "$@" 2>/dev/null || true; } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── +require_root() { + if [[ $EUID -ne 0 ]]; then + echo -e "${RED} Run as root: sudo $0${RST}" + exit 1 + fi +} + +check_deps() { + local missing=() + for cmd in ping ip ss awk grep tee sysctl; do + command -v "$cmd" &>/dev/null || missing+=("$cmd") + done + command -v iperf3 &>/dev/null && HAS_IPERF3=1 || true + command -v ethtool &>/dev/null && HAS_ETHTOOL=1 || true + command -v tc &>/dev/null && HAS_TC=1 || true + command -v birdc &>/dev/null && HAS_BIRD=1 || true + command -v wg &>/dev/null || true # optional, graceful fallback + if [[ ${#missing[@]} -gt 0 ]]; then + echo -e "${RED} Missing: ${missing[*]}${RST}" + echo -e "${RED} Debian : apt install iproute2 iputils-ping${RST}" + echo -e "${RED} Alma : dnf install iproute iputils${RST}" + exit 1 + fi +} + +# ── Role detection ──────────────────────────────────────────────────────────── +# Inspects the machine and determines what kind of node it is. +# Sets: ROLE, WG_IFACES, WG_MTU, WG_PEERS, WG_PUBSUBNETS, +# BIRD_RUNNING, BIRD_PROTOCOLS, IP_FORWARD +# Also auto-sets EXPECTED_MTU if not already provided by --expected-mtu +detect_role() { + sect "0. Machine Role Detection [read-only]" + + # ── ip_forward ──────────────────────────────────────────────────────────── + IP_FORWARD=$(safe_read sysctl -n net.ipv4.ip_forward) + info "ip_forward = ${IP_FORWARD:-0}" + + # ── WireGuard interfaces ────────────────────────────────────────────────── + local wg_found=0 + while IFS= read -r line; do + if [[ "$line" =~ ^[0-9]+:\ (wg[^:]+): ]]; then + local wname="${BASH_REMATCH[1]}" + WG_IFACES+=("$wname") + wg_found=1 + fi + done < <(ip link show 2>/dev/null) + + # Also catch WireGuard interfaces that use other names (check type via ethtool/ip) + while IFS= read -r iname; do + local already=0 + for w in "${WG_IFACES[@]:-}"; do [[ "$w" == "$iname" ]] && already=1; done + if [[ $already -eq 0 ]]; then + local ltype; ltype=$(ip -d link show "$iname" 2>/dev/null | grep -o 'wireguard' || true) + [[ -n "$ltype" ]] && WG_IFACES+=("$iname") && wg_found=1 + fi + done < <(ip -d link show 2>/dev/null | awk '/wireguard/{print prev} {prev=$2}' | tr -d ':' || true) + + if [[ ${#WG_IFACES[@]} -gt 0 ]]; then + info "WireGuard interfaces found: ${WG_IFACES[*]}" + + # MTU of first WG interface + WG_MTU=$(safe_read ip link show "${WG_IFACES[0]}" | \ + awk '/mtu/{for(i=1;i<=NF;i++) if($i=="mtu") print $(i+1)}') + info "WireGuard MTU (${WG_IFACES[0]}): ${BLD}${WG_MTU}${RST}" + + # Peer count + routed public subnets via 'wg show' (read-only) + if command -v wg &>/dev/null; then + for wif in "${WG_IFACES[@]}"; do + local peer_count + peer_count=$(safe_read wg show "$wif" peers | wc -l) + WG_PEERS=$(( WG_PEERS + peer_count )) + + # Collect allowed-ips that are public (not RFC1918/loopback/link-local) + while IFS= read -r aip; do + # Strip peer pubkey prefix if present + local subnet; subnet=$(echo "$aip" | grep -oP '[\d.]+/\d+' | head -1) + [[ -z "$subnet" ]] && continue + local first_octet; first_octet=$(echo "$subnet" | cut -d. -f1) + local second_octet; second_octet=$(echo "$subnet" | cut -d. -f2) + # Skip RFC1918, loopback, link-local, 0.0.0.0 + case "$first_octet" in + 10|127) continue ;; + 172) [[ $second_octet -ge 16 && $second_octet -le 31 ]] && continue ;; + 192) [[ $second_octet -eq 168 ]] && continue ;; + 0) continue ;; + esac + # Skip /32 host routes (single peers) — only show /24 and larger blocks + local prefix; prefix=$(echo "$subnet" | cut -d/ -f2) + [[ $prefix -gt 30 ]] && continue + WG_PUBSUBNETS+=("$subnet (via $wif)") + done < <(safe_read wg show "$wif" allowed-ips | awk '{for(i=2;i<=NF;i++) print $i}') + done + info "WireGuard total peers : ${BLD}${WG_PEERS}${RST}" + if [[ ${#WG_PUBSUBNETS[@]} -gt 0 ]]; then + info "Routed public subnets :" + for s in "${WG_PUBSUBNETS[@]}"; do + log " ${GRN}▸${RST} $s" + done + else + info "Routed public subnets : none (point-to-point or private only)" + fi + else + info " (install 'wireguard-tools' for peer/subnet detail)" + fi + + # Auto-set EXPECTED_MTU from WG MTU only if user didn't already specify it + if [[ $EXPECTED_MTU -eq 0 && ${WG_MTU:-0} -gt 0 ]]; then + EXPECTED_MTU=$WG_MTU + info "Auto-set --expected-mtu=${EXPECTED_MTU} from WireGuard interface MTU" + elif [[ $EXPECTED_MTU -gt 0 && ${WG_MTU:-0} -gt 0 && $EXPECTED_MTU -ne $WG_MTU ]]; then + info "Using --expected-mtu=${EXPECTED_MTU} (override; WG interface MTU is ${WG_MTU})" + fi + else + info "No WireGuard interfaces detected" + fi + + # ── BIRD routing daemon ─────────────────────────────────────────────────── + log "" + if systemctl is-active --quiet bird 2>/dev/null || \ + systemctl is-active --quiet bird2 2>/dev/null || \ + pgrep -x bird &>/dev/null || pgrep -x bird2 &>/dev/null; then + BIRD_RUNNING=1 + info "BIRD routing daemon: ${GRN}running${RST}" + + if [[ $HAS_BIRD -eq 1 ]]; then + # Read active protocols (BGP, OSPF, etc.) — birdc is read-only + local proto_out + proto_out=$(safe_read birdc show protocols | grep -E 'BGP|OSPF|BFD|Static|Babel' || true) + if [[ -n "$proto_out" ]]; then + info "Active routing protocols:" + echo "$proto_out" | sed 's/^/ /' | tee -a "$LOG_FILE" + # Extract names of up BGP sessions + while IFS= read -r pline; do + local pname pstate + pname=$(echo "$pline" | awk '{print $1}') + pstate=$(echo "$pline" | awk '{print $4}') + [[ "$pstate" == "Established" || "$pstate" == "up" ]] && \ + BIRD_PROTOCOLS+=("$pname") + done <<< "$proto_out" + fi + + # Route table summary + local route_count + route_count=$(safe_read birdc show route count | grep -oP '\d+ of \d+' | head -1 || echo "unknown") + info "Route table: ${route_count} routes" + else + info " (install 'bird2' package for protocol detail via birdc)" + fi + else + info "BIRD routing daemon: not running" + fi + + # ── Determine role ──────────────────────────────────────────────────────── + log "" + local is_wg=$(( ${#WG_IFACES[@]} > 0 ? 1 : 0 )) + local is_router=$(( IP_FORWARD == 1 ? 1 : 0 )) + local is_bird=$BIRD_RUNNING + local has_pubsubnets=$(( ${#WG_PUBSUBNETS[@]} > 0 ? 1 : 0 )) + + if [[ $is_bird -eq 1 && $is_wg -eq 1 ]]; then ROLE="wg-bird-router" + elif [[ $is_bird -eq 1 ]]; then ROLE="bird-router" + elif [[ $is_wg -eq 1 && $is_router -eq 1 ]]; then ROLE="wg-router" + elif [[ $is_wg -eq 1 ]]; then ROLE="wg-client" + elif [[ $is_router -eq 1 ]]; then ROLE="router" + else ROLE="vps" + fi + + # Role label + description + case "$ROLE" in + vps) local role_label="VPS / plain server" + local role_desc="No routing, no tunnels detected" ;; + wg-client) local role_label="WireGuard client" + local role_desc="WireGuard present, ip_forward off" ;; + wg-router) local role_label="WireGuard gateway / router" + local role_desc="WireGuard + ip_forward=1" ;; + bird-router) local role_label="BGP/OSPF router (BIRD)" + local role_desc="BIRD running, no WireGuard" ;; + wg-bird-router) local role_label="WireGuard + BGP router (BIRD)" + local role_desc="Full routing stack: WireGuard tunnel + BIRD BGP/OSPF" ;; + router) local role_label="Generic router" + local role_desc="ip_forward=1, no WireGuard or BIRD" ;; + esac + + log "" + log " ${BLD}${CYN}┌─────────────────────────────────────────────┐${RST}" + log " ${BLD}${CYN}│ Detected role : ${BLD}${role_label}${RST}" + log " ${BLD}${CYN}│ ${role_desc}${RST}" + [[ ${WG_MTU:-0} -gt 0 ]] && \ + log " ${BLD}${CYN}│ WireGuard MTU : ${WG_MTU} → expected path MTU set to ${EXPECTED_MTU}${RST}" + [[ $has_pubsubnets -eq 1 ]] && \ + log " ${BLD}${CYN}│ Public subnets: ${#WG_PUBSUBNETS[@]} routed block(s) in WireGuard${RST}" + [[ ${#BIRD_PROTOCOLS[@]} -gt 0 ]] && \ + log " ${BLD}${CYN}│ BGP sessions : ${BIRD_PROTOCOLS[*]}${RST}" + log " ${BLD}${CYN}└─────────────────────────────────────────────┘${RST}" + log "" + + pass "Role detected: ${role_label}" + + # Role-specific hints that feed into the rest of the test + case "$ROLE" in + wg-router|wg-bird-router) + info "Role context: testing as WireGuard gateway" + info " → MTU verdicts will use WG MTU ${EXPECTED_MTU} as baseline" + info " → MSS clamping check applies to FORWARD chain" + if [[ $has_pubsubnets -eq 1 ]]; then + info " → Routed public subnets detected — checking tunnel carries full subnet range" + fi ;; + bird-router) + info "Role context: testing as BGP/OSPF router" + info " → Checking for MTU consistency across routing interfaces" ;; + vps|wg-client) + info "Role context: testing as end-host / VPS" + info " → Standard MTU path check, no routing concerns" ;; + esac +} + +detect_iface() { + # ── If interface was passed as CLI arg, skip TUI ───────────────────────── + if [[ -n "$IFACE" ]]; then + if ! ip link show "$IFACE" &>/dev/null; then + echo -e "${RED} Interface '$IFACE' not found${RST}" + exit 1 + fi + _print_iface_summary + return + fi + + # ── Collect interfaces from ip link ────────────────────────────────────── + local -a iface_names iface_mtus iface_states iface_ips iface_types + + while IFS= read -r line; do + # Line like: "2: eth0: mtu 1500 ..." + if [[ "$line" =~ ^[0-9]+:\ ([^:]+):\ \<([^>]+)\>.*mtu\ ([0-9]+) ]]; then + local name="${BASH_REMATCH[1]// /}" # trim spaces + local flags="${BASH_REMATCH[2]}" + local mtu="${BASH_REMATCH[3]}" + + # Skip loopback + [[ "$name" == "lo" ]] && continue + + # State + local state="DOWN" + [[ "$flags" == *"UP"* ]] && state="UP" + [[ "$flags" == *"LOWER_UP"* ]] && state="UP" + + # Type hint + local type="ethernet" + [[ "$name" == wg* ]] && type="wireguard" + [[ "$name" == tun* ]] && type="tun" + [[ "$name" == tap* ]] && type="tap" + [[ "$name" == veth* ]] && type="veth" + [[ "$name" == br* ]] && type="bridge" + [[ "$name" == bond* ]] && type="bond" + [[ "$name" == vlan* || "$name" == *"."* ]] && type="vlan" + [[ "$name" == dummy* ]] && type="dummy" + + # IP address (next addr line for this iface) + local ip + ip=$(ip addr show "$name" 2>/dev/null \ + | awk '/inet /{print $2}' | head -1) + [[ -z "$ip" ]] && ip="(no IP)" + + iface_names+=("$name") + iface_mtus+=("$mtu") + iface_states+=("$state") + iface_ips+=("$ip") + iface_types+=("$type") + fi + done < <(ip link show 2>/dev/null) + + if [[ ${#iface_names[@]} -eq 0 ]]; then + echo -e "${RED} No network interfaces found${RST}" + exit 1 + fi + + # ── Draw TUI ───────────────────────────────────────────────────────────── + local selected=0 + local total=${#iface_names[@]} + + _draw_tui() { + # Move cursor up $total + header + footer lines if not first draw + if [[ "${_tui_drawn:-0}" -eq 1 ]]; then + # +5 = 2 header + 1 blank + 1 prompt + 1 blank above table + printf '\033[%dA' $(( total + 5 )) + fi + _tui_drawn=1 + + echo -e "" + echo -e " ${BLD}${CYN}Select network interface to test:${RST}" + echo -e " ${CYN}Use ↑/↓ arrow keys, Enter to confirm${RST}" + echo -e "" + + local i + for (( i=0; i/dev/null; then + printf " %-8s ${GRN}OK${RST}\n" "$size" | tee -a "$LOG_FILE" + [[ $size -gt $max_ok ]] && max_ok=$size + else + printf " %-8s ${RED}FAIL${RST}\n" "$size" | tee -a "$LOG_FILE" + fi + done + + log "" + info "Largest successful ICMP size (step-down): ${BLD}${max_ok} bytes${RST}" + + local iface_mtu + iface_mtu=$(safe_read ip link show "$IFACE" | awk '/mtu/{for(i=1;i<=NF;i++) if($i=="mtu") print $(i+1)}') + + local baseline=$(( EXPECTED_MTU > 0 ? EXPECTED_MTU : 1500 )) + # Step-down list gaps are up to ~100 bytes (e.g. 1400→1300). + # The exact path MTU is determined by binary bisect in section 3. + # Here we only need to confirm: is the largest OK size plausibly + # consistent with the expected (or auto-detected) path MTU? + local tolerance=110 + + if [[ $max_ok -ge $(( baseline - tolerance )) ]]; then + if [[ $EXPECTED_MTU -gt 0 ]]; then + pass "Step-down largest OK=${max_ok} — consistent with expected path MTU ${EXPECTED_MTU}" + info " Note: step-down list has no probe at ${EXPECTED_MTU}, so ${max_ok} is the closest lower step" + info " Section 3 binary bisect gives the exact value" + else + pass "Full 1500-byte path confirmed — no fragmentation detected" + fi + elif [[ $max_ok -ge 1400 ]]; then + if [[ $EXPECTED_MTU -gt 0 ]]; then + warn "Step-down largest OK=${max_ok} is well below expected ${EXPECTED_MTU} — check Section 3 for exact path MTU" + else + warn "Path MTU around ${max_ok} (below interface MTU ${iface_mtu}) — tunnel overhead suspected" + info " Hint: WireGuard → 1420 | PPPoE → 1492 | VXLAN/GRE → 1450" + fi + elif [[ $max_ok -ge 576 ]]; then + if [[ $EXPECTED_MTU -gt 0 && $max_ok -ge $(( EXPECTED_MTU - tolerance )) ]]; then + pass "Step-down largest OK=${max_ok} — consistent with expected path MTU ${EXPECTED_MTU}" + info " Step-down list gap: no probe between ${max_ok} and $(( max_ok + 100 )), bisect in Section 3 gives exact value" + else + case "$ROLE" in + vps|wg-client) + info "Path MTU appears reduced (${max_ok}) — likely upstream tunnel, use --expected-mtu if intentional" ;; + *) + fail "Path MTU severely reduced (${max_ok}) — check firewall DF-blocking or misconfigured tunnel" ;; + esac + fi + else + fail "No ICMP sizes succeeded — target unreachable or ICMP fully blocked" + fi +} + +# ── Section 3: Binary bisect ────────────────────────────────────────────────── +section_pmtu_bisect() { + sect "3. Binary-Search: Exact Path MTU" + info "Bisecting between 576 and 1500 bytes..." + + local lo=576 hi=1500 mid best=0 + + while [[ $lo -le $hi ]]; do + mid=$(( (lo + hi) / 2 )) + local payload=$(( mid - 28 )) + if ping -c 2 -W 2 -M do -s "$payload" "$TARGET" &>/dev/null; then + best=$mid; lo=$(( mid + 1 )) + else + hi=$(( mid - 1 )) + fi + done + + if [[ $best -eq 0 ]]; then + fail "Bisect failed — ICMP may be filtered by target or firewall" + return + fi + + info "Exact path MTU: ${BLD}${best} bytes${RST}" + + local baseline=$(( EXPECTED_MTU > 0 ? EXPECTED_MTU : 1500 )) + local tolerance=10 + + if [[ $best -ge $(( baseline - tolerance )) && $best -le $(( baseline + tolerance )) ]]; then + if [[ $EXPECTED_MTU -gt 0 ]]; then + pass "Path MTU=${best} — matches expected tunnel MTU ${EXPECTED_MTU} ✓" + else + pass "Full 1500 path MTU — no overhead" + fi + elif [[ $best -ge 1400 ]]; then + if [[ $EXPECTED_MTU -gt 0 ]]; then + warn "Path MTU=${best} differs from expected ${EXPECTED_MTU} — check tunnel config" + else + case "$ROLE" in + vps|wg-client) + info "Path MTU=${best} — reduced by upstream tunnel, use --expected-mtu ${best} to suppress this" ;; + *) + warn "Path MTU=${best} — apply MSS clamping or adjust tunnel MTU" ;; + esac + fi + else + if [[ $EXPECTED_MTU -gt 0 && $best -ge $(( EXPECTED_MTU - tolerance )) ]]; then + pass "Path MTU=${best} — matches expected tunnel MTU ${EXPECTED_MTU} ✓" + else + case "$ROLE" in + vps|wg-client) + if [[ $EXPECTED_MTU -eq 0 ]]; then + info "Path MTU=${best} — reduced by upstream infrastructure, use --expected-mtu ${best} if intentional" + else + fail "Path MTU=${best} is below expected ${EXPECTED_MTU} — upstream tunnel MTU worse than configured" + fi ;; + *) + fail "Path MTU=${best} — significant restriction, investigate middleboxes" ;; + esac + fi + fi +} + +# ── Section 4: TCP MSS inspection ───────────────────────────────────────────── +section_tcp_mss() { + sect "4. TCP MSS & Active Socket Inspection [read-only]" + + info "MSS distribution across active TCP connections (ss -tin):" + local mss_data + mss_data=$(safe_read ss -tin | grep -Eo 'mss:[0-9]+') + + if [[ -z "$mss_data" ]]; then + info " No active TCP connections with MSS data found" + else + echo "$mss_data" | sort | uniq -c | sort -rn | \ + awk '{printf " %-6s connections at %s\n", $1, $2}' | tee -a "$LOG_FILE" + + local dominant_mss + dominant_mss=$(echo "$mss_data" | awk -F: '{print $2}' | sort | uniq -c | sort -rn | awk 'NR==1{print $2}') + log "" + # Expected MSS = MTU - 40 (20 IP + 20 TCP headers) + local expected_mss=$(( EXPECTED_MTU > 0 ? EXPECTED_MTU - 40 : 1460 )) + local mss_tolerance=10 + + if [[ "${dominant_mss:-0}" -ge $(( expected_mss - mss_tolerance )) && \ + "${dominant_mss:-0}" -le $(( expected_mss + mss_tolerance )) ]]; then + if [[ $EXPECTED_MTU -gt 0 ]]; then + pass "Dominant MSS=${dominant_mss} — matches expected tunnel MSS ${expected_mss} ✓" + else + pass "Dominant MSS=1460 — standard 1500-byte path" + fi + elif [[ "${dominant_mss:-0}" -ge 1400 ]]; then + case "$ROLE" in + vps|wg-client) + info "Dominant MSS=${dominant_mss} — slightly reduced, consistent with upstream tunnel clamping" ;; + *) + warn "Dominant MSS=${dominant_mss} — slightly below expected ${expected_mss}" ;; + esac + elif [[ "${dominant_mss:-0}" -gt 0 ]]; then + if [[ $EXPECTED_MTU -gt 0 ]]; then + case "$ROLE" in + vps|wg-client) + info "Dominant MSS=${dominant_mss} — reduced by upstream WireGuard router clamping, expected for this setup" ;; + *) + warn "Low dominant MSS=${dominant_mss} — expected ~${expected_mss} for tunnel MTU ${EXPECTED_MTU}" ;; + esac + else + case "$ROLE" in + vps|wg-client) + info "Dominant MSS=${dominant_mss} — may be reduced by upstream router or tunnel" ;; + *) + warn "Low dominant MSS=${dominant_mss} — clamping or tunnel in path" ;; + esac + fi + fi + fi + + if command -v iptables &>/dev/null; then + log "" + info "iptables TCPMSS clamping rules (list only, no changes):" + local tcpmss_rules + tcpmss_rules=$(safe_read iptables -t mangle -L FORWARD -n --line-numbers | grep -i "TCPMSS") + if [[ -n "$tcpmss_rules" ]]; then + echo "$tcpmss_rules" | sed 's/^/ /' | tee -a "$LOG_FILE" + pass "MSS clamping rule(s) present" + else + case "$ROLE" in + wg-router|wg-bird-router) + warn "No TCPMSS clamping rule in mangle FORWARD — required on WireGuard router to prevent MTU black holes" + info " iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu" + info " (script does NOT add it — recommendation only)" ;; + bird-router|router) + warn "No TCPMSS clamping rule — recommended on routing nodes with tunnel interfaces" ;; + vps|wg-client) + info "No TCPMSS clamping rule — not needed on a VPS/client (ip_forward is off, no packet forwarding)" ;; + *) + if [[ $EXPECTED_MTU -gt 0 ]]; then + info "No TCPMSS clamping rule — may be intentional if clamping is done upstream" + else + warn "No TCPMSS clamping rule in mangle FORWARD — recommended when using tunnels" + fi ;; + esac + fi + fi +} + +# ── Section 5: Interface error counters ─────────────────────────────────────── +section_iface_errors() { + sect "5. Interface Error Counters — Inbound & Outbound [read-only]" + + local proc_line + proc_line=$(grep -E "^\s*${IFACE}:" /proc/net/dev 2>/dev/null || true) + + if [[ -z "$proc_line" ]]; then + fail "Interface '${IFACE}' not found in /proc/net/dev" + return + fi + + # Parse /proc/net/dev columns + local fields; IFS=': ' read -ra fields <<< "$proc_line" + local clean=() + for f in "${fields[@]}"; do [[ -n "$f" ]] && clean+=("$f"); done + + local rx_bytes="${clean[1]:-0}" rx_pkts="${clean[2]:-0}" + local rx_errs="${clean[3]:-0}" rx_drop="${clean[4]:-0}" + local tx_bytes="${clean[9]:-0}" tx_pkts="${clean[10]:-0}" + local tx_errs="${clean[11]:-0}" tx_drop="${clean[12]:-0}" + + log "" + log " Direction Bytes Packets Errors Drops" + log " ───────── ────────────── ─────────── ────── ─────" + printf " %-12s %-16s %-13s %-8s %s\n" "RX (in)" "$rx_bytes" "$rx_pkts" "$rx_errs" "$rx_drop" | tee -a "$LOG_FILE" + printf " %-12s %-16s %-13s %-8s %s\n" "TX (out)" "$tx_bytes" "$tx_pkts" "$tx_errs" "$tx_drop" | tee -a "$LOG_FILE" + log "" + + local all_ok=1 + [[ "${rx_errs:-0}" -gt 0 ]] && { fail "RX errors: ${rx_errs}"; all_ok=0; } + [[ "${rx_drop:-0}" -gt 0 ]] && { warn "RX drops: ${rx_drop} (may be firewall drops, not necessarily MTU)"; all_ok=0; } + [[ "${tx_errs:-0}" -gt 0 ]] && { fail "TX errors: ${tx_errs}"; all_ok=0; } + [[ "${tx_drop:-0}" -gt 0 ]] && { warn "TX drops: ${tx_drop}"; all_ok=0; } + [[ $all_ok -eq 1 ]] && pass "No RX/TX errors or drops on ${IFACE}" + + log "" + info "ip -s link output (read-only):" + safe_read ip -s link show "$IFACE" | sed 's/^/ /' | tee -a "$LOG_FILE" + + if [[ $HAS_ETHTOOL -eq 1 ]]; then + log "" + info "ethtool NIC stats — fragmentation-relevant (read-only):" + local eth_stats + eth_stats=$(safe_read ethtool -S "$IFACE" | grep -iE 'frag|oversize|giant|jabber|mtu|error|drop') + if [[ -n "$eth_stats" ]]; then + echo "$eth_stats" | sed 's/^/ /' | tee -a "$LOG_FILE" + else + info " No fragmentation-related ethtool stats found" + fi + fi +} + +# ── Public iperf3 server list — organised by region → country ──────────────── +# Format per entry: "host port region country" +# Regions: EU, NA, ASIA, OCE +# Phase 1: one representative per region pinged in parallel → pick best region(s) +# Phase 2: all servers in winning region(s) pinged 2 at a time → pick best country +# Phase 3: top 3 servers of best country retested 10× → winner selected +declare -A IPERF3_REGION_REPR=( + [EU]="speedtest.ams1.nl.leaseweb.net" + [NA]="nyc.speedtest.clouvider.net" + [ASIA]="speedtest.tyo11.jp.leaseweb.net" + [OCE]="speedtest.syd12.au.leaseweb.net" +) + +IPERF3_SERVERS=( + # EU — NL + "iperf-ams-nl.eranium.net 5201 EU NL" + "speedtest.ams1.nl.leaseweb.net 5201 EU NL" + "speedtest.ams2.nl.leaseweb.net 5201 EU NL" + "ams.speedtest.clouvider.net 5200 EU NL" + "speedtest.ams1.novogara.net 5200 EU NL" + "ping-ams1.online.net 5200 EU NL" + "speedtest.netone.nl 5201 EU NL" + "iperf.worldstream.nl 5201 EU NL" + # EU — DE + "fra.speedtest.clouvider.net 5200 EU DE" + "speedtest.fra1.de.leaseweb.net 5201 EU DE" + "speedtest.wtnet.de 5200 EU DE" + "a205.speedtest.wobcom.de 5201 EU DE" + # EU — GB + "lon.speedtest.clouvider.net 5200 EU GB" + "speedtest.lon1.uk.leaseweb.net 5201 EU GB" + # EU — CH + "speedtest.init7.net 5201 EU CH" + # EU — FR + "iperf.online.net 5200 EU FR" + "ping.online.net 5200 EU FR" + # EU — SE + "speedtest.keff.org 9201 EU SE" + # NA — US + "nyc.speedtest.clouvider.net 5201 NA US" + "speedtest.nyc1.us.leaseweb.net 5201 NA US" + "dal.speedtest.clouvider.net 5200 NA US" + "speedtest.dal13.us.leaseweb.net 5201 NA US" + "la.speedtest.clouvider.net 5200 NA US" + # NA — CA + "speedtest.mtl2.ca.leaseweb.net 5201 NA CA" + # ASIA — JP + "speedtest.tyo11.jp.leaseweb.net 5201 ASIA JP" + # ASIA — SG + "speedtest.sin1.sg.leaseweb.net 5201 ASIA SG" + "sgp.proof.ovh.net 5201 ASIA SG" + # OCE — AU + "speedtest.syd12.au.leaseweb.net 5201 OCE AU" + "syd.proof.ovh.net 5201 OCE AU" +) + +IPERF3_BEST_HOST="" +IPERF3_BEST_PORT="" + +# ── Helper: ping a host, write "avg host port" to a file ───────────────────── +# Usage: _ping_server "host" "port" "count" "outfile" +_ping_server() { + local host="$1" port="$2" count="$3" outfile="$4" + local out avg loss + out=$(ping -c "$count" -W 2 -q "$host" 2>/dev/null || true) + avg=$(echo "$out" | grep -oP 'rtt.*=\s*[\d.]+/\K[\d.]+' || true) + loss=$(echo "$out" | grep -oP '\d+(?=% packet loss)' || echo "100") + if [[ -n "$avg" && "${loss:-100}" -lt 50 ]]; then + echo "$avg $host $port $loss" >> "$outfile" + fi +} + +select_iperf3_server() { + sect "6a. Public iperf3 Server Selection [parallel ping, read-only]" + + if [[ $HAS_IPERF3 -eq 0 ]]; then + skip "iperf3 not installed — skipping server selection" + return + fi + + mkdir -p "$TMPDIR_PINGS" + + # ═══════════════════════════════════════════════════════════════════════════ + # PHASE 1 — ping one representative per region in parallel (3 pings each) + # Goal: eliminate distant regions fast + # ═══════════════════════════════════════════════════════════════════════════ + info "Phase 1: Pinging region representatives in parallel (3 pings each)..." + log "" + + local region_file="${TMPDIR_PINGS}/phase1_regions.txt" + : > "$region_file" + local pids=() + + for region in "${!IPERF3_REGION_REPR[@]}"; do + local repr="${IPERF3_REGION_REPR[$region]}" + ( + local out avg loss + out=$(ping -c 3 -W 2 -q "$repr" 2>/dev/null || true) + avg=$(echo "$out" | grep -oP 'rtt.*=\s*[\d.]+/\K[\d.]+' || true) + loss=$(echo "$out" | grep -oP '\d+(?=% packet loss)' || echo "100") + if [[ -n "$avg" && "${loss:-100}" -lt 60 ]]; then + echo "$avg $region" >> "$region_file" + printf " %-6s %-44s ${GRN}avg %7s ms${RST}\n" \ + "[$region]" "$repr" "$avg" | tee -a "$LOG_FILE" + else + printf " %-6s %-44s ${YEL}unreachable${RST}\n" \ + "[$region]" "$repr" | tee -a "$LOG_FILE" + fi + ) & + pids+=($!) + done + + for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done + + if [[ ! -s "$region_file" ]]; then + warn "No regions reachable — falling back to loopback" + return + fi + + # Pick regions within 2× the best region's RTT (keeps nearby regions) + local best_region_rtt + best_region_rtt=$(sort -n "$region_file" | head -1 | awk '{print $1}' | cut -d. -f1) + local rtt_cutoff=$(( best_region_rtt * 2 + 20 )) + + local winning_regions=() + while IFS= read -r line; do + local rtt region + rtt=$(echo "$line" | awk '{print $1}' | cut -d. -f1) + region=$(echo "$line" | awk '{print $2}') + [[ $rtt -le $rtt_cutoff ]] && winning_regions+=("$region") + done < <(sort -n "$region_file") + + log "" + info "Winning region(s): ${BLD}${winning_regions[*]}${RST} (cutoff ${rtt_cutoff} ms)" + + # ═══════════════════════════════════════════════════════════════════════════ + # PHASE 2 — ping all servers in winning regions, 2 at a time (4 pings each) + # Goal: find the best country within the winning region(s) + # ═══════════════════════════════════════════════════════════════════════════ + log "" + info "Phase 2: Pinging all servers in winning region(s), 2 at a time (4 pings)..." + log "" + + local country_file="${TMPDIR_PINGS}/phase2_countries.txt" + : > "$country_file" + + # Collect servers in winning regions + local -a region_servers=() + for entry in "${IPERF3_SERVERS[@]}"; do + local host port region country + read -r host port region country <<< "$entry" + for wr in "${winning_regions[@]}"; do + if [[ "$region" == "$wr" ]]; then + region_servers+=("$host $port $country") + break + fi + done + done + + # Run in batches of 2 in parallel + local batch_pids=() + local batch_count=0 + for entry in "${region_servers[@]}"; do + local host port country + read -r host port country <<< "$entry" + + ( + local out avg loss + out=$(ping -c 4 -W 2 -q "$host" 2>/dev/null || true) + avg=$(echo "$out" | grep -oP 'rtt.*=\s*[\d.]+/\K[\d.]+' || true) + loss=$(echo "$out" | grep -oP '\d+(?=% packet loss)' || echo "100") + if [[ -n "$avg" && "${loss:-100}" -lt 30 ]]; then + echo "$avg $host $port $country" >> "$country_file" + printf " [%2s] %-44s ${GRN}avg %7s ms loss %s%%${RST}\n" \ + "$country" "$host" "$avg" "${loss:-0}" | tee -a "$LOG_FILE" + else + printf " [%2s] %-44s ${YEL}skip (loss %s%%)${RST}\n" \ + "$country" "$host" "${loss:-100}" | tee -a "$LOG_FILE" + fi + ) & + batch_pids+=($!) + batch_count=$(( batch_count + 1 )) + + if [[ $batch_count -ge 2 ]]; then + for pid in "${batch_pids[@]}"; do wait "$pid" 2>/dev/null || true; done + batch_pids=() + batch_count=0 + fi + done + # Flush remaining + for pid in "${batch_pids[@]}"; do wait "$pid" 2>/dev/null || true; done + + if [[ ! -s "$country_file" ]]; then + warn "Phase 2: no servers responded — falling back to loopback" + return + fi + + # Best country = country of the top-3 servers by avg RTT + local best_country + best_country=$(sort -n "$country_file" | head -3 | awk '{print $4}' | sort | uniq -c | sort -rn | awk 'NR==1{print $2}') + log "" + info "Best country: ${BLD}${best_country}${RST}" + + # ═══════════════════════════════════════════════════════════════════════════ + # PHASE 3 — retest top-3 servers of best country with 10 pings each (serial) + # Goal: accurate final selection + # ═══════════════════════════════════════════════════════════════════════════ + log "" + info "Phase 3: Retesting top 3 servers in ${best_country} with 10 pings each..." + log "" + + local final_file="${TMPDIR_PINGS}/phase3_final.txt" + : > "$final_file" + + local top3 + top3=$(sort -n "$country_file" | awk -v c="$best_country" '$4==c' | head -3) + + while IFS= read -r line; do + local host port country + host=$(echo "$line" | awk '{print $2}') + port=$(echo "$line" | awk '{print $3}') + + printf " %-44s " "$host" | tee -a "$LOG_FILE" + + local out avg loss + out=$(ping -c 10 -W 2 -q "$host" 2>/dev/null || true) + avg=$(echo "$out" | grep -oP 'rtt.*=\s*[\d.]+/\K[\d.]+' || true) + loss=$(echo "$out" | grep -oP '\d+(?=% packet loss)' || echo "100") + + if [[ -n "$avg" && "${loss:-100}" -lt 20 ]]; then + printf "${GRN}avg %7s ms loss %s%%${RST}\n" "$avg" "${loss:-0}" | tee -a "$LOG_FILE" + echo "$avg $host $port" >> "$final_file" + else + printf "${RED}skip (loss %s%%)${RST}\n" "${loss:-100}" | tee -a "$LOG_FILE" + fi + done <<< "$top3" + + if [[ ! -s "$final_file" ]]; then + warn "Phase 3: all top servers failed retest — falling back to loopback" + return + fi + + local winner + winner=$(sort -n "$final_file" | head -1) + IPERF3_BEST_HOST=$(echo "$winner" | awk '{print $2}') + IPERF3_BEST_PORT=$(echo "$winner" | awk '{print $3}') + local best_rtt + best_rtt=$(echo "$winner" | awk '{print $1}') + + log "" + pass "Selected: ${BLD}${IPERF3_BEST_HOST}:${IPERF3_BEST_PORT}${RST} (avg RTT ${best_rtt} ms, ${best_country})" +} + +# ── Section 6: Load test ────────────────────────────────────────────────────── +# +# Strategy: +# Phase A — iperf3 loopback TCP flood (no remote server needed) +# Spins up iperf3 -s on 127.0.0.1:15201, then runs 4 parallel +# client streams for LOAD_DURATION seconds with MSS 1460. +# This saturates the NIC TX/RX path the way real TCP traffic does. +# +# Phase B — Concurrent MTU ICMP probes +# While the iperf3 flood is running, a second background job fires +# step-down ICMP probes (DF-bit) at the real TARGET. +# If any size that passed idle now fails under load → MTU problem. +# +# Phase C — Counter delta +# /proc/net/dev RX/TX error + drop deltas before vs after. +# +# Falls back to flood-ping only if iperf3 is not installed. +# ============================================================================= +section_load_test() { + sect "6b. MTU Under Load [iperf3 real-path + concurrent ICMP probes]" + + if [[ $NO_LOAD -eq 1 ]]; then + skip "Load test skipped (--no-load)" + return + fi + + mkdir -p "$TMPDIR_PINGS" + + # ── Snapshot /proc counters before ─────────────────────────────────────── + local pre_line; pre_line=$(grep -E "^\s*${IFACE}:" /proc/net/dev 2>/dev/null || true) + local pre=(); IFS=': ' read -ra pre <<< "$pre_line" + local pre_clean=() + for f in "${pre[@]}"; do [[ -n "$f" ]] && pre_clean+=("$f"); done + local pre_rx_errs="${pre_clean[3]:-0}" pre_rx_drop="${pre_clean[4]:-0}" + local pre_tx_errs="${pre_clean[11]:-0}" pre_tx_drop="${pre_clean[12]:-0}" + + # ── Decide: external server or loopback ────────────────────────────────── + local iperf_host="" iperf_port="" iperf_mode="" + local iperf_server_pid="" iperf_used=0 + local _iperf_retransmits="" # set during parse, consumed in deferred verdict block + + if [[ $HAS_IPERF3 -eq 1 ]]; then + if [[ -n "$IPERF3_BEST_HOST" && -n "$IPERF3_BEST_PORT" ]]; then + iperf_host="$IPERF3_BEST_HOST" + iperf_port="$IPERF3_BEST_PORT" + iperf_mode="external" + info "Using external iperf3 server: ${BLD}${iperf_host}:${iperf_port}${RST}" + info "Traffic will traverse your real NIC path — this is the most realistic test." + else + # Loopback fallback + local IPERF_PORT=15201 + if ss -tlnp 2>/dev/null | grep -q ":${IPERF_PORT} "; then + warn "Port ${IPERF_PORT} in use — cannot start loopback server" + else + iperf3 -s -B 127.0.0.1 -p "$IPERF_PORT" --one-off \ + > "${TMPDIR_PINGS}/iperf_server.txt" 2>&1 & + iperf_server_pid=$! + sleep 0.8 + if kill -0 "$iperf_server_pid" 2>/dev/null; then + iperf_host="127.0.0.1" + iperf_port="$IPERF_PORT" + iperf_mode="loopback" + warn "No public server available — using loopback fallback (less realistic)" + info " Retransmits on loopback at high throughput are normal, not an MTU indicator." + else + warn "iperf3 loopback server failed to start" + iperf_server_pid="" + fi + fi + fi + fi + + if [[ -n "$iperf_host" ]]; then + info "Running ${LOAD_PARALLEL}x parallel TCP streams, MSS 1460, ${LOAD_DURATION}s..." + + iperf3 -c "$iperf_host" -p "$iperf_port" \ + -t "$LOAD_DURATION" \ + -P "$LOAD_PARALLEL" \ + -M 1460 \ + --logfile "${TMPDIR_PINGS}/iperf_client.txt" \ + > /dev/null 2>&1 & + local iperf_client_pid=$! + iperf_used=1 + + # ── Phase B: concurrent ICMP MTU probes while TCP load is running ──── + info "Firing concurrent ICMP MTU probes (DF-bit) while TCP load is active..." + local -a probe_sizes=(1500 1472 1450 1420 1400 1300 1000) + declare -A probe_results_load=() + + for size in "${probe_sizes[@]}"; do + local payload=$(( size - 28 )) + if ping -c 3 -W 2 -M do -s "$payload" "$TARGET" &>/dev/null; then + probe_results_load[$size]="OK" + else + probe_results_load[$size]="FAIL" + fi + done + + # Progress bar + local elapsed=0 + while kill -0 "$iperf_client_pid" 2>/dev/null && [[ $elapsed -lt $(( LOAD_DURATION + 5 )) ]]; do + local pct=$(( elapsed * 100 / LOAD_DURATION )) + [[ $pct -gt 100 ]] && pct=100 + local bar_filled=$(( pct / 5 )) + local bar="" + for (( b=0; b<20; b++ )); do + [[ $b -lt $bar_filled ]] && bar+="█" || bar+="░" + done + printf "\r ${CYN}[%s] %3d%% iperf3 [%s] running...${RST}" "$bar" "$pct" "$iperf_mode" + sleep 1 + elapsed=$(( elapsed + 1 )) + done + printf "\r ${GRN}[████████████████████] 100%% iperf3 complete ${RST}\n" + + wait "$iperf_client_pid" 2>/dev/null || true + + # Kill loopback server if we started one + if [[ -n "$iperf_server_pid" ]]; then + kill "$iperf_server_pid" 2>/dev/null || true + wait "$iperf_server_pid" 2>/dev/null || true + fi + + # ── Parse iperf3 output ─────────────────────────────────────────────── + log "" + info "iperf3 results (${iperf_mode}, MSS 1460, ${LOAD_PARALLEL} streams):" + if [[ -f "${TMPDIR_PINGS}/iperf_client.txt" ]]; then + grep -E '\[SUM\]|\[ ID\]|sender|receiver|error|connect' "${TMPDIR_PINGS}/iperf_client.txt" \ + | sed 's/^/ /' | tee -a "$LOG_FILE" || true + + local retransmits + retransmits=$(grep -E 'SUM.*sender' "${TMPDIR_PINGS}/iperf_client.txt" \ + | awk '{print $(NF-1)}' 2>/dev/null || echo "?") + # fallback parse + [[ "$retransmits" == "?" ]] && \ + retransmits=$(grep -oP '\d+(?= sender)' "${TMPDIR_PINGS}/iperf_client.txt" | tail -1 || echo "?") + + log "" + if [[ "$iperf_mode" == "external" ]]; then + # External retransmits are only meaningful when corroborated by + # ICMP failures. If --expected-mtu is set and ICMP ≤expected MTU + # is clean, retransmits are caused by PMTUD MSS renegotiation + # during TCP warmup (kernel starts at MSS 1460, path reduces it + # to MSS = expected_mtu - 40 in the first few seconds), not an + # actual MTU problem. We defer the verdict until after ICMP results. + _iperf_retransmits="$retransmits" # picked up after ICMP block + else + # Loopback: retransmits are congestion control noise, not MTU signal + if [[ "${retransmits:-0}" == "0" ]]; then + pass "iperf3 retransmits: 0 (loopback)" + elif [[ "${retransmits:-?}" =~ ^[0-9]+$ ]]; then + info "iperf3 retransmits: ${retransmits} (loopback congestion control — not an MTU indicator)" + else + info "iperf3 retransmits: could not parse" + fi + fi + else + warn "iperf3 client output not found — server may have refused connection" + fi + + # ── Report concurrent ICMP results ──────────────────────────────────── + log "" + info "Concurrent ICMP probe results (fired during TCP load on real path):" + log " Size Result" + log " ──── ──────" + local concurrent_fail=0 + for size in "${probe_sizes[@]}"; do + local res="${probe_results_load[$size]:-SKIP}" + if [[ "$res" == "OK" ]]; then + printf " %-8s ${GRN}OK${RST}\n" "$size" | tee -a "$LOG_FILE" + else + printf " %-8s ${RED}FAIL${RST}\n" "$size" | tee -a "$LOG_FILE" + concurrent_fail=$(( concurrent_fail + 1 )) + fi + done + + log "" + if [[ $concurrent_fail -eq 0 ]]; then + pass "All ICMP sizes passed under TCP load — no MTU regression under load" + else + # If expected MTU is set, check if only sizes above it failed + if [[ $EXPECTED_MTU -gt 0 ]]; then + local unexpected_fail=0 + for size in "${probe_sizes[@]}"; do + local res="${probe_results_load[$size]:-SKIP}" + if [[ "$res" == "FAIL" && $size -le $EXPECTED_MTU ]]; then + unexpected_fail=$(( unexpected_fail + 1 )) + fi + done + if [[ $unexpected_fail -eq 0 ]]; then + pass "ICMP sizes ≤${EXPECTED_MTU} all passed — failures above expected tunnel MTU are normal ✓" + else + fail "${unexpected_fail} ICMP size(s) ≤${EXPECTED_MTU} failed under load — MTU instability below expected tunnel MTU" + fi + else + fail "${concurrent_fail} ICMP size(s) failed under load — MTU instability under real traffic" + fi + fi + + # ── Deferred retransmit verdict ─────────────────────────────────────── + # For external mode we deferred the verdict until we knew the ICMP result. + # For loopback we still do a simple correlation pass. + local retr="${_iperf_retransmits:-}" + if [[ "$iperf_mode" == "external" && -n "$retr" ]]; then + log "" + + # Determine if ICMP was clean at/below the expected MTU baseline + local icmp_ok=1 + if [[ $EXPECTED_MTU -gt 0 ]]; then + # Only count failures at or below expected MTU as real problems + for size in "${probe_sizes[@]}"; do + local res="${probe_results_load[$size]:-SKIP}" + [[ "$res" == "FAIL" && $size -le $(( EXPECTED_MTU + 10 )) ]] && icmp_ok=0 + done + else + [[ $concurrent_fail -gt 0 ]] && icmp_ok=0 + fi + + if [[ "$retr" == "0" ]]; then + pass "iperf3 retransmits: 0 — clean TCP over real network path" + elif [[ $icmp_ok -eq 1 ]]; then + # ICMP clean at expected MTU → retransmits are PMTUD warmup: + # kernel starts SYN at MSS 1460, path clamps to MSS=(path_mtu-40) + # in first RTTs, those early segments retransmit. Expected behaviour. + local settled_mss=$(( EXPECTED_MTU > 0 ? EXPECTED_MTU - 40 : 1330 )) + info "iperf3 retransmits: ${retr} — ICMP path clean at/below expected MTU ${EXPECTED_MTU:-1500}" + info " TCP PMTUD warmup: kernel opens at MSS 1460, path reduces to MSS ${settled_mss}" + info " in the first 1-2 seconds → burst of retransmits, then settles. Normal." + pass "Retransmit correlation: consistent with PMTUD warmup, not an MTU problem ✓" + elif [[ "${retr}" =~ ^[0-9]+$ ]] && [[ "$retr" -lt 50 ]]; then + warn "iperf3 retransmits: ${retr} — minor, correlate with ICMP failures above" + else + fail "iperf3 retransmits: ${retr} — corroborated by ICMP failures ≤expected MTU, real MTU problem" + fi + elif [[ "$iperf_mode" == "loopback" ]] && \ + [[ "${_iperf_retransmits:-?}" =~ ^[0-9]+$ ]] && [[ "${_iperf_retransmits}" -gt 0 ]] && \ + [[ $concurrent_fail -eq 0 ]]; then + pass "Retransmit correlation: ICMP probes clean — loopback retransmits are not MTU-related" + fi + fi + + # ── Fallback: flood-ping if iperf3 completely unavailable ──────────────── + if [[ $iperf_used -eq 0 ]]; then + warn "iperf3 not available — using flood-ping fallback" + info " Install: apt install iperf3 / dnf install iperf3" + log "" + info "Launching ${LOAD_PARALLEL} parallel ping streams (payload=1472, DF-bit, ${LOAD_DURATION}s)..." + + local pids=() + local count=$(( LOAD_DURATION * 20 )) + for (( i=0; i "${TMPDIR_PINGS}/ping_${i}.txt" 2>&1 & + pids+=($!) + done + + local elapsed=0 + while [[ $elapsed -lt $LOAD_DURATION ]]; do + local pct=$(( elapsed * 100 / LOAD_DURATION )) + local bar_filled=$(( pct / 5 )) + local bar="" + for (( b=0; b<20; b++ )); do + [[ $b -lt $bar_filled ]] && bar+="█" || bar+="░" + done + printf "\r ${CYN}[%s] %3d%% %ds / %ds${RST}" "$bar" "$pct" "$elapsed" "$LOAD_DURATION" + sleep 1 + elapsed=$(( elapsed + 1 )) + done + printf "\r ${GRN}[████████████████████] 100%% Complete ${RST}\n" + + for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done + + local total_sent=0 total_recv=0 max_loss=0 + for (( i=0; i/dev/null || echo 0) + recv=$(grep -oP '\d+(?= received)' "$f" 2>/dev/null || echo 0) + loss=$(grep -oP '\d+(?=% packet loss)' "$f" 2>/dev/null || echo 0) + total_sent=$(( total_sent + sent )) + total_recv=$(( total_recv + recv )) + [[ ${loss:-0} -gt $max_loss ]] && max_loss=${loss:-0} + done + + log "" + log " Packets sent : $total_sent" + log " Packets received : $total_recv" + log " Max stream loss : ${max_loss}%" + log "" + + if [[ $max_loss -eq 0 ]]; then pass "Zero packet loss under load at 1472-byte payload" + elif [[ $max_loss -le 2 ]]; then warn "Minor loss (${max_loss}%) — may be rate-limiting at target" + else fail "Loss ${max_loss}% under load — MTU fragmentation or buffer issue" + fi + fi + + # ── Phase C: counter delta ──────────────────────────────────────────────── + local post_line; post_line=$(grep -E "^\s*${IFACE}:" /proc/net/dev 2>/dev/null || true) + local post=(); IFS=': ' read -ra post <<< "$post_line" + local post_clean=() + for f in "${post[@]}"; do [[ -n "$f" ]] && post_clean+=("$f"); done + + local delta_rx_errs=$(( ${post_clean[3]:-0} - pre_rx_errs )) + local delta_rx_drop=$(( ${post_clean[4]:-0} - pre_rx_drop )) + local delta_tx_errs=$(( ${post_clean[11]:-0} - pre_tx_errs )) + local delta_tx_drop=$(( ${post_clean[12]:-0} - pre_tx_drop )) + + log "" + info "Interface counter delta (entire load test period):" + log " New RX errors : $delta_rx_errs" + log " New RX drops : $delta_rx_drop" + log " New TX errors : $delta_tx_errs" + log " New TX drops : $delta_tx_drop" + + if [[ $delta_rx_errs -eq 0 && $delta_rx_drop -eq 0 && $delta_tx_errs -eq 0 && $delta_tx_drop -eq 0 ]]; then + pass "No new RX/TX errors or drops during load test" + else + fail "New errors/drops during load — check MTU mismatch, ring buffer size, or NIC driver" + fi +} + +# ── Section 7: tc / QoS read ────────────────────────────────────────────────── +section_tc() { + [[ $HAS_TC -eq 0 ]] && return + sect "7. Traffic Control (tc) QoS Inspection [read-only]" + + info "qdiscs on ${IFACE}:" + safe_read tc qdisc show dev "$IFACE" | sed 's/^/ /' | tee -a "$LOG_FILE" + + local mpu + mpu=$(safe_read tc qdisc show dev "$IFACE" | grep -oP 'mpu \K\d+' || true) + if [[ -n "$mpu" ]]; then + info "Minimum Packet Unit (mpu): $mpu" + [[ "${mpu:-0}" -le 64 ]] && pass "mpu=$mpu looks reasonable" || \ + warn "mpu=$mpu is large — may affect small-packet handling" + else + info " No mpu value set in qdisc (kernel default)" + fi + + info "" + info "tc filters on ${IFACE}:" + safe_read tc filter show dev "$IFACE" | sed 's/^/ /' | tee -a "$LOG_FILE" || info " (none)" +} + +# ── Section 8: Summary ──────────────────────────────────────────────────────── +section_summary() { + sect "8. Summary & Recommendations" + + local iface_mtu + iface_mtu=$(safe_read ip link show "$IFACE" | awk '/mtu/{for(i=1;i<=NF;i++) if($i=="mtu") print $(i+1)}') + + log " Role : ${BLD}${ROLE}${RST}" + log " Interface : ${IFACE} (MTU ${iface_mtu})" + [[ ${WG_MTU:-0} -gt 0 ]] && log " WG MTU : ${WG_MTU} (${#WG_IFACES[@]} interface(s): ${WG_IFACES[*]})" + [[ ${#WG_PUBSUBNETS[@]} -gt 0 ]] && log " Pub subnets: ${WG_PUBSUBNETS[*]}" + [[ $BIRD_RUNNING -eq 1 ]] && log " BIRD : running sessions: ${BIRD_PROTOCOLS[*]:-none}" + log " Target : ${TARGET}" + [[ $EXPECTED_MTU -gt 0 ]] && log " Exp. MTU : ${EXPECTED_MTU} (path MTU baseline)" + log " Issues : ${ISSUES_FOUND} warning(s)/failure(s)" + log "" + # ── Role-aware fixes box ────────────────────────────────────────────────── + case "$ROLE" in + vps|wg-client) + log " ┌──────────────────────────────────────────────────────────────────┐" + log " │ Relevant fixes for this VPS / client │" + log " ├──────────────────────────────────────────────────────────────────┤" + log " │ PMTUD on sysctl -w net.ipv4.tcp_mtu_probing=1 │" + log " │ Persist echo 'net.ipv4.tcp_mtu_probing=1' │" + log " │ >> /etc/sysctl.d/99-mtu.conf │" + if [[ $EXPECTED_MTU -gt 0 ]]; then + log " │ Path MTU ${EXPECTED_MTU} is set by upstream router — no local fix needed │" + fi + log " │ If issues persist: contact your hoster about path MTU │" + log " │ tracepath 8.8.8.8 ← shows which hop reduces MTU │" + log " └──────────────────────────────────────────────────────────────────┘" ;; + wg-router|wg-bird-router) + local wg_mtu_display="${WG_MTU:-1420}" + local wg_mss=$(( wg_mtu_display - 40 )) + log " ┌──────────────────────────────────────────────────────────────────┐" + log " │ Relevant fixes for this WireGuard router │" + log " ├──────────────────────────────────────────────────────────────────┤" + log " │ WG MTU ip link set ${WG_IFACES[0]:-wg0} mtu ${wg_mtu_display} │" + log " │ MSS clamp iptables -t mangle -A FORWARD │" + log " │ -p tcp --tcp-flags SYN,RST SYN │" + log " │ -j TCPMSS --set-mss ${wg_mss} │" + log " │ Also clamp iptables -t mangle -A OUTPUT │" + log " │ -p tcp --tcp-flags SYN,RST SYN │" + log " │ -j TCPMSS --set-mss ${wg_mss} │" + log " │ PMTUD on sysctl -w net.ipv4.tcp_mtu_probing=1 │" + log " │ Persist echo 'net.ipv4.tcp_mtu_probing=1' │" + log " │ >> /etc/sysctl.d/99-mtu.conf │" + log " └──────────────────────────────────────────────────────────────────┘" ;; + bird-router|router) + log " ┌──────────────────────────────────────────────────────────────────┐" + log " │ Relevant fixes for this router │" + log " ├──────────────────────────────────────────────────────────────────┤" + log " │ MSS clamp iptables -t mangle -A FORWARD │" + log " │ -p tcp --tcp-flags SYN,RST SYN │" + log " │ -j TCPMSS --clamp-mss-to-pmtu │" + log " │ PMTUD on sysctl -w net.ipv4.tcp_mtu_probing=1 │" + log " │ Persist echo 'net.ipv4.tcp_mtu_probing=1' │" + log " │ >> /etc/sysctl.d/99-mtu.conf │" + log " └──────────────────────────────────────────────────────────────────┘" ;; + *) + log " ┌──────────────────────────────────────────────────────────────────┐" + log " │ General fixes (apply manually — this script makes NO changes) │" + log " ├──────────────────────────────────────────────────────────────────┤" + log " │ WireGuard ip link set wg0 mtu 1420 │" + log " │ PPPoE ip link set ppp0 mtu 1492 │" + log " │ VXLAN/GRE ip link set vxlan0 mtu 1450 │" + log " │ MSS clamp iptables -t mangle -A FORWARD │" + log " │ -p tcp --tcp-flags SYN,RST SYN │" + log " │ -j TCPMSS --clamp-mss-to-pmtu │" + log " │ PMTUD on sysctl -w net.ipv4.tcp_mtu_probing=1 │" + log " │ Persist echo 'net.ipv4.tcp_mtu_probing=1' │" + log " │ >> /etc/sysctl.d/99-mtu.conf │" + log " └──────────────────────────────────────────────────────────────────┘" ;; + esac + log "" + log " Full log: ${BLD}${LOG_FILE}${RST}" +} + +# ── Main ────────────────────────────────────────────────────────────────────── +main() { + echo -e "${BLD}${CYN}" + echo -e " ╔═══════════════════════════════════════════════╗" + echo -e " ║ MTU DIAGNOSTIC TOOL v2.6 ║" + echo -e " ║ READ-ONLY — no system changes are made ║" + echo -e " ╚═══════════════════════════════════════════════╝${RST}" + echo "" + echo -e " ${CYN}Started : $(date)${RST}" + echo "" + + { + echo "MTU Diagnostic Tool v2.5" + echo "READ-ONLY — no system changes made" + echo "Started : $(date)" + echo "---" + } > "$LOG_FILE" + + require_root + check_deps + detect_role + detect_iface + + section_interface + section_ping_mtu + section_pmtu_bisect + section_tcp_mss + section_iface_errors + select_iperf3_server + section_load_test + section_tc + section_summary + + sect "Complete" + if [[ $ISSUES_FOUND -eq 0 ]]; then pass "All checks passed — no MTU issues detected" + elif [[ $ISSUES_FOUND -le 3 ]]; then warn "${ISSUES_FOUND} issue(s) found — review WARNs/FAILs above" + else fail "${ISSUES_FOUND} issue(s) found — MTU configuration needs attention" + fi + + log "" + log " Log: ${BLD}${LOG_FILE}${RST}" +} + +main "$@" \ No newline at end of file