#!/usr/bin/env bash # ============================================================================= # HorizonBench.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 ./HorizonBench.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 ./HorizonBench.sh # sudo ./HorizonBench.sh 1.2.3.4 eth0 # sudo ./HorizonBench.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/horizonbench_$(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}" RPT_PATH_MTU="$max_ok" 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}" RPT_EXACT_MTU="$best" 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}') RPT_DOMINANT_MSS="$dominant_mss" 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" RPT_CLAMPING="present" else RPT_CLAMPING="none" 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}" RPT_RX_ERRS="${rx_errs:-0}"; RPT_RX_DROP="${rx_drop:-0}" RPT_TX_ERRS="${tx_errs:-0}"; RPT_TX_DROP="${tx_drop:-0}" 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="" # ── Report tracking — populated during test sections ───────────────────────── RPT_IFACE_MTU="" RPT_PATH_MTU="" RPT_EXACT_MTU="" RPT_DOMINANT_MSS="" RPT_RX_ERRS=0; RPT_RX_DROP=0; RPT_TX_ERRS=0; RPT_TX_DROP=0 RPT_IPERF_SERVER="" RPT_IPERF_THROUGHPUT="" RPT_IPERF_RETRANSMITS="" RPT_IPERF_VERDICT="" RPT_LOAD_ERRS=0; RPT_LOAD_DROPS=0 RPT_CLAMPING="" RPT_PMTUD_PROBE="" # ── 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})" RPT_IPERF_SERVER="${IPERF3_BEST_HOST}:${IPERF3_BEST_PORT} (${best_country}, RTT ${best_rtt} ms)" } # ── 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 # Determine the correct MSS to use: # Priority: 1) dominant MSS from section 4 2) expected_mtu-40 3) 1460 local iperf_mss=1460 if [[ -n "$RPT_DOMINANT_MSS" && "$RPT_DOMINANT_MSS" =~ ^[0-9]+$ && "$RPT_DOMINANT_MSS" -lt 1460 ]]; then iperf_mss="$RPT_DOMINANT_MSS" elif [[ $EXPECTED_MTU -gt 0 ]]; then iperf_mss=$(( EXPECTED_MTU - 40 )) fi info "Running ${LOAD_PARALLEL}x parallel TCP streams, MSS ${iperf_mss}, ${LOAD_DURATION}s..." [[ $iperf_mss -lt 1460 ]] && \ info " Using MSS ${iperf_mss} (derived from ${RPT_DOMINANT_MSS:+detected dominant MSS}${RPT_DOMINANT_MSS:-expected MTU ${EXPECTED_MTU}}) — matches path MTU" iperf3 -c "$iperf_host" -p "$iperf_port" \ -t "$LOAD_DURATION" \ -P "$LOAD_PARALLEL" \ -M "$iperf_mss" \ --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 ${iperf_mss}, ${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 "?") # Capture throughput for report local throughput throughput=$(grep -E 'SUM.*sender' "${TMPDIR_PINGS}/iperf_client.txt" \ | awk '{for(i=1;i<=NF;i++) if($i~/bits/) print $(i-1)" "$i}' | tail -1 || echo "") RPT_IPERF_RETRANSMITS="${retransmits:-?}" RPT_IPERF_THROUGHPUT="${throughput:-unknown}" 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" RPT_IPERF_VERDICT="clean (0 retransmits)" elif [[ $icmp_ok -eq 1 ]]; then local settled_mss=$(( EXPECTED_MTU > 0 ? EXPECTED_MTU - 40 : 1460 )) if [[ "$iperf_mss" -lt 1460 ]]; then # Correct MSS was used from the start — retransmits are # pure TCP congestion control on the real network path, # not MSS negotiation. Normal for a shared public iperf3 server. info "iperf3 retransmits: ${retr} — ICMP path clean, MSS ${iperf_mss} was correct from the start" info " Retransmits are TCP congestion control on the real path — normal for shared public servers" else 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." fi pass "Retransmit correlation: ICMP clean, no MTU problem ✓" RPT_IPERF_VERDICT="TCP congestion control (${retr} retransmits, normal)" elif [[ "${retr}" =~ ^[0-9]+$ ]] && [[ "$retr" -lt 50 ]]; then warn "iperf3 retransmits: ${retr} — minor, correlate with ICMP failures above" RPT_IPERF_VERDICT="minor retransmits (${retr})" else fail "iperf3 retransmits: ${retr} — corroborated by ICMP failures ≤expected MTU, real MTU problem" RPT_IPERF_VERDICT="HIGH retransmits (${retr}) — 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" RPT_LOAD_ERRS=0; RPT_LOAD_DROPS=0 else fail "New errors/drops during load — check MTU mismatch, ring buffer size, or NIC driver" RPT_LOAD_ERRS=$(( delta_rx_errs + delta_tx_errs )) RPT_LOAD_DROPS=$(( delta_rx_drop + delta_tx_drop )) 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}" } # ── Shareable summary report ────────────────────────────────────────────────── print_report() { local ts; ts=$(date '+%Y-%m-%d %H:%M') local hostname; hostname=$(hostname -f 2>/dev/null || hostname) # Overall verdict string local overall_verdict overall_col if [[ $ISSUES_FOUND -eq 0 ]]; then overall_verdict="PASS — no issues detected" overall_col="$GRN" elif [[ $ISSUES_FOUND -le 3 ]]; then overall_verdict="WARN — ${ISSUES_FOUND} issue(s) found" overall_col="$YEL" else overall_verdict="FAIL — ${ISSUES_FOUND} issue(s) found" overall_col="$RED" fi # Interface counter summary local iface_status if [[ "${RPT_RX_ERRS:-0}" -eq 0 && "${RPT_RX_DROP:-0}" -eq 0 && \ "${RPT_TX_ERRS:-0}" -eq 0 && "${RPT_TX_DROP:-0}" -eq 0 ]]; then iface_status="${GRN}clean${RST}" else iface_status="${RED}errors/drops present${RST}" fi # Load test counter summary local load_status if [[ "${RPT_LOAD_ERRS:-0}" -eq 0 && "${RPT_LOAD_DROPS:-0}" -eq 0 ]]; then load_status="${GRN}clean${RST}" else load_status="${RED}new errors/drops under load${RST}" fi # Path MTU verdict local path_mtu_str="${RPT_EXACT_MTU:-unknown}" local path_mtu_col="$GRN" if [[ -n "$RPT_EXACT_MTU" && $EXPECTED_MTU -gt 0 ]]; then local diff=$(( RPT_EXACT_MTU - EXPECTED_MTU )) [[ ${diff#-} -gt 10 ]] && path_mtu_col="$YEL" elif [[ -n "$RPT_EXACT_MTU" && "${RPT_EXACT_MTU}" -lt 1490 && $EXPECTED_MTU -eq 0 ]]; then path_mtu_col="$YEL" fi # Clamping local clamp_str case "${RPT_CLAMPING:-none}" in present) clamp_str="${GRN}present${RST}" ;; none) case "$ROLE" in wg-router|wg-bird-router) clamp_str="${RED}MISSING${RST}" ;; vps|wg-client) clamp_str="${CYN}not needed (VPS)${RST}" ;; *) clamp_str="${YEL}not found${RST}" ;; esac ;; *) clamp_str="${YEL}unknown${RST}" ;; esac echo "" echo -e "${BLD}${CYN}╔══════════════════════════════════════════════════════════════════╗${RST}" echo -e "${BLD}${CYN}║ HorizonBench — MTU Test Report ║${RST}" echo -e "${BLD}${CYN}╚══════════════════════════════════════════════════════════════════╝${RST}" echo "" printf " %-20s %s\n" "Host:" "$hostname" printf " %-20s %s\n" "Date:" "$ts" printf " %-20s %s\n" "Role:" "$ROLE" printf " %-20s %s\n" "Interface:" "${IFACE} (iface MTU ${RPT_IFACE_MTU:-?})" printf " %-20s %s\n" "Test target:" "$TARGET" [[ $EXPECTED_MTU -gt 0 ]] && \ printf " %-20s %s\n" "Expected MTU:" "$EXPECTED_MTU" echo "" echo -e " ${BLD}── Path MTU ──────────────────────────────────────────────────────${RST}" printf " %-20s %b\n" "Step-down probe:" "${path_mtu_col}${RPT_PATH_MTU:-unknown} bytes${RST} (closest lower step in probe list)" printf " %-20s %b\n" "Exact (bisect):" "${path_mtu_col}${path_mtu_str} bytes${RST}" [[ $EXPECTED_MTU -gt 0 ]] && \ printf " %-20s %b\n" "vs expected:" "${path_mtu_col}${EXPECTED_MTU} bytes${RST}" echo "" echo -e " ${BLD}── TCP ──────────────────────────────────────────────────────────${RST}" printf " %-20s %s\n" "Dominant MSS:" "${RPT_DOMINANT_MSS:-unknown}" printf " %-20s %b\n" "MSS clamping:" "$clamp_str" printf " %-20s %s\n" "PMTUD probing:" "${RPT_PMTUD_PROBE:-0} (tcp_mtu_probing sysctl)" echo "" echo -e " ${BLD}── Interface counters ───────────────────────────────────────────${RST}" printf " %-20s %b\n" "Idle counters:" "$iface_status" printf " %-20s %b\n" "Under load:" "$load_status" echo "" echo -e " ${BLD}── Load test ────────────────────────────────────────────────────${RST}" if [[ -n "$RPT_IPERF_SERVER" ]]; then printf " %-20s %s\n" "iperf3 server:" "$RPT_IPERF_SERVER" printf " %-20s %s\n" "Throughput:" "${RPT_IPERF_THROUGHPUT:-unknown}" printf " %-20s %s\n" "Retransmits:" "${RPT_IPERF_VERDICT:-unknown}" else printf " %-20s %s\n" "Load test:" "flood-ping fallback (iperf3 not installed)" fi [[ ${#WG_IFACES[@]} -gt 0 ]] && { echo "" echo -e " ${BLD}── WireGuard ─────────────────────────────────────────────────────${RST}" printf " %-20s %s\n" "Interfaces:" "${WG_IFACES[*]}" printf " %-20s %s\n" "Tunnel MTU:" "${WG_MTU:-unknown}" printf " %-20s %s\n" "Peers:" "${WG_PEERS:-0}" [[ ${#WG_PUBSUBNETS[@]} -gt 0 ]] && \ printf " %-20s %s\n" "Public subnets:" "${WG_PUBSUBNETS[*]}" } [[ $BIRD_RUNNING -eq 1 ]] && { echo "" echo -e " ${BLD}── BIRD ──────────────────────────────────────────────────────────${RST}" printf " %-20s %s\n" "Daemon:" "running" [[ ${#BIRD_PROTOCOLS[@]} -gt 0 ]] && \ printf " %-20s %s\n" "BGP sessions:" "${BIRD_PROTOCOLS[*]}" } echo "" echo -e " ${BLD}── Overall ──────────────────────────────────────────────────────${RST}" echo -e " ${overall_col}${BLD} ${overall_verdict}${RST}" echo "" echo -e " Full log: ${BLD}${LOG_FILE}${RST}" echo -e "${BLD}${CYN}══════════════════════════════════════════════════════════════════${RST}" echo "" # Also write plain-text version to log (no colour codes) { echo "" echo "════════════════════════════════════════════════════════════════════" echo " HorizonBench — MTU Test Report" echo "════════════════════════════════════════════════════════════════════" echo " Host : $hostname" echo " Date : $ts" echo " Role : $ROLE" echo " Interface : ${IFACE} (iface MTU ${RPT_IFACE_MTU:-?})" echo " Test target : $TARGET" [[ $EXPECTED_MTU -gt 0 ]] && echo " Expected MTU : $EXPECTED_MTU" echo "" echo " Path MTU" echo " Step-down : ${RPT_PATH_MTU:-unknown} bytes" echo " Exact (bisect) : ${path_mtu_str} bytes" [[ $EXPECTED_MTU -gt 0 ]] && echo " vs expected : ${EXPECTED_MTU} bytes" echo "" echo " TCP" echo " Dominant MSS : ${RPT_DOMINANT_MSS:-unknown}" echo " MSS clamping : ${RPT_CLAMPING:-none}" echo " PMTUD probing : ${RPT_PMTUD_PROBE:-0}" echo "" echo " Interface counters" echo " Idle : RX errs=${RPT_RX_ERRS:-0} drops=${RPT_RX_DROP:-0} TX errs=${RPT_TX_ERRS:-0} drops=${RPT_TX_DROP:-0}" echo " Under load : new errs=${RPT_LOAD_ERRS:-0} drops=${RPT_LOAD_DROPS:-0}" echo "" echo " Load test" if [[ -n "$RPT_IPERF_SERVER" ]]; then echo " Server : $RPT_IPERF_SERVER" echo " Throughput : ${RPT_IPERF_THROUGHPUT:-unknown}" echo " Retransmits : ${RPT_IPERF_VERDICT:-unknown}" else echo " Method : flood-ping fallback" fi [[ ${#WG_IFACES[@]} -gt 0 ]] && { echo "" echo " WireGuard" echo " Interfaces : ${WG_IFACES[*]}" echo " Tunnel MTU : ${WG_MTU:-unknown}" echo " Peers : ${WG_PEERS:-0}" [[ ${#WG_PUBSUBNETS[@]} -gt 0 ]] && echo " Public subnets : ${WG_PUBSUBNETS[*]}" } [[ $BIRD_RUNNING -eq 1 ]] && { echo "" echo " BIRD" echo " Daemon : running" [[ ${#BIRD_PROTOCOLS[@]} -gt 0 ]] && echo " BGP sessions : ${BIRD_PROTOCOLS[*]}" } echo "" echo " Overall : $( [[ $ISSUES_FOUND -eq 0 ]] && echo "PASS" || echo "WARN/FAIL" ) — ${ISSUES_FOUND} issue(s)" echo "════════════════════════════════════════════════════════════════════" } >> "$LOG_FILE" } # ── 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 print_report } main "$@"