#!/bin/sh # # Log WAN vs LAN ping quality from any vantage (router, FE, Pi, …). # Use to compare "rocketship" evenings vs slow periods (ISP/upstream vs local). # # Topology (see docs/INFRA.md): # world -> 134.17.26.161 (router 10.7.6.228) -> 10.7.0.10 (FE) -> 10.7.0.0/8 (BE, …) # # Install: # sudo cp wan-snapshot.sh /usr/local/bin/wan-snapshot.sh # sudo chmod +x /usr/local/bin/wan-snapshot.sh # sudo mkdir -p /var/log # # Cron (every 15 minutes — ONE entry only; script uses flock to skip overlaps): # */15 * * * * root /usr/local/bin/wan-snapshot.sh >>/var/log/wan-quality.cron.log 2>&1 # # Env overrides: # WAN_SNAPSHOT_LOG=/var/log/wan-quality.log # WAN_SNAPSHOT_ROLE=auto|router|fe|pi (default auto from hostname) # WAN_SNAPSHOT_EXTRAS=1 (0 = ping only, compact v1-style line) # WAN_SNAPSHOT_LOCK=/var/run/wan-snapshot.lock # WAN_PING_TARGET=1.1.1.1 # FE_PING_TARGET=10.7.0.10 # BE_PING_TARGET=10.7.16.128 (set to "none" to skip) # ROUTER_PING_TARGET=none|10.7.6.228 (optional; useful on Pi) # GW_PING_TARGET=auto|none| (default auto = default gateway) # PING_COUNT=10 # PING_DEADLINE_S=15 # set -eu WAN_SNAPSHOT_LOG="${WAN_SNAPSHOT_LOG:-/var/log/wan-quality.log}" WAN_SNAPSHOT_ROLE="${WAN_SNAPSHOT_ROLE:-auto}" WAN_SNAPSHOT_EXTRAS="${WAN_SNAPSHOT_EXTRAS:-1}" WAN_SNAPSHOT_LOCK="${WAN_SNAPSHOT_LOCK:-/var/run/wan-snapshot.lock}" WAN_PING_TARGET="${WAN_PING_TARGET:-1.1.1.1}" FE_PING_TARGET="${FE_PING_TARGET:-10.7.0.10}" BE_PING_TARGET="${BE_PING_TARGET-10.7.16.128}" ROUTER_PING_TARGET="${ROUTER_PING_TARGET:-none}" GW_PING_TARGET="${GW_PING_TARGET:-auto}" PING_COUNT="${PING_COUNT:-10}" PING_DEADLINE_S="${PING_DEADLINE_S:-15}" usage() { sed -n '2,28p' "$0" printf '\nCommands:\n' printf ' (default) append one snapshot line to the log\n' printf ' --report summarize today vs yesterday\n' printf ' --tail [N] print last N log lines (default 24)\n' printf ' --correlate FILE… merge logs by 15-min slot (role= or path basename)\n' printf ' --help this text\n' } need_ping() { command -v ping >/dev/null 2>&1 || { echo "wan-snapshot: missing ping" >&2 exit 1 } } detect_role() { _role="$WAN_SNAPSHOT_ROLE" if [ "$_role" != "auto" ]; then printf '%s' "$_role" return 0 fi _hn="$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown)" case "$_hn" in *raspberry*|*rpi*) printf 'pi' ;; *monstro*|*router*|*gw*) printf 'router' ;; *gentoo*|*wg0*|*fe*) printf 'fe' ;; *) printf 'host' ;; esac } # ping_summary HOST -> loss_pct min_ms avg_ms max_ms mdev_ms (-1 for missing) ping_summary() { _host="$1" _out="" _out="$(ping -c "$PING_COUNT" -w "$PING_DEADLINE_S" "$_host" 2>/dev/null)" || true if [ -z "$_out" ]; then printf '%s %s %s %s %s' "-1" "-1" "-1" "-1" "-1" return 0 fi _loss="$(printf '%s\n' "$_out" | awk '/packet loss/ { for (i = 1; i <= NF; i++) if ($i ~ /%$/) { gsub(/%/, "", $i); print $i; exit } }')" _min="-1" _avg="-1" _max="-1" _mdev="-1" _rtt="$(printf '%s\n' "$_out" | sed -n 's/.*= \([0-9.]*\)\/\([0-9.]*\)\/\([0-9.]*\)\/\([0-9.]*\) ms.*/\1 \2 \3 \4/p' | head -1)" if [ -n "$_rtt" ]; then _min="${_rtt%% *}" _rest="${_rtt#* }" _avg="${_rest%% *}" _rest="${_rest#* }" _max="${_rest%% *}" _mdev="${_rest#* }" else _avg="$(printf '%s\n' "$_out" | awk -F'[=/ ]' '/min\/avg\/max/ { print $8; exit }')" [ -n "$_avg" ] || _avg="-1" fi [ -n "$_loss" ] || _loss="-1" printf '%s %s %s %s %s' "$_loss" "$_min" "$_avg" "$_max" "$_mdev" } append_ping_fields() { _line="$1" _prefix="$2" _host="$3" _sum="$(ping_summary "$_host")" _loss="${_sum%% *}" _rest="${_sum#* }" _min="${_rest%% *}" _rest="${_rest#* }" _avg="${_rest%% *}" _rest="${_rest#* }" _max="${_rest%% *}" _mdev="${_rest#* }" printf '%s %s=%s %s_loss=%s%% %s_min_ms=%s %s_avg_ms=%s %s_max_ms=%s %s_mdev_ms=%s' \ "$_line" "$_prefix" "$_host" "$_prefix" "$_loss" "$_prefix" "$_min" \ "$_prefix" "$_avg" "$_prefix" "$_max" "$_prefix" "$_mdev" } route_dev() { _host="$1" ip route get "$_host" 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit }}' } route_gw() { _host="$1" ip route get "$_host" 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "via") { print $(i + 1); exit }}' } default_gw() { ip route show default 2>/dev/null | awk '{print $3; exit}' } load_1m() { awk '{print $1}' /proc/loadavg 2>/dev/null || echo "n/a" } cpu_busy_pct() { awk '/^cpu / { idle = $5 + $6; total = 0 for (i = 2; i <= NF; i++) total += $i if (total > 0) printf "%.0f", 100 * (total - idle) / total }' /proc/stat 2>/dev/null || echo "n/a" } conntrack_pct() { _cur="$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null)" || _cur="" _max="$(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null)" || _max="" if [ -n "$_cur" ] && [ -n "$_max" ] && [ "$_max" -gt 0 ] 2>/dev/null; then _pct=$((_cur * 100 / _max)) printf 'conntrack=%s/%s(%s%%)' "$_cur" "$_max" "$_pct" fi } neigh_state() { _host="$1" ip neigh show "$_host" 2>/dev/null | awk '{print $6; exit}' } iface_drops_compact() { ip -s link 2>/dev/null | awk ' /^[0-9]+:/ { if (iface != "" && iface !~ /^lo$/) { if (rx_drop + tx_drop + rx_err + tx_err > 0) printf "%s:rd=%d,td=%d,re=%d,te=%d ", iface, rx_drop, tx_drop, rx_err, tx_err } iface = $2; gsub(":", "", iface) rx_drop = tx_drop = rx_err = tx_err = 0 } /RX:/ { getline; rx_err = $3; rx_drop = $4 } /TX:/ { getline; tx_err = $3; tx_drop = $4 } END { if (iface != "" && iface !~ /^lo$/ && rx_drop + tx_drop + rx_err + tx_err > 0) printf "%s:rd=%d,td=%d,re=%d,te=%d", iface, rx_drop, tx_drop, rx_err, tx_err } ' | sed 's/ $//' } acquire_lock() { command -v flock >/dev/null 2>&1 || return 0 _lock="$WAN_SNAPSHOT_LOCK" _dir="$(dirname "$_lock")" if ! mkdir -p "$_dir" 2>/dev/null || ! : >"$_lock" 2>/dev/null; then _lock="${TMPDIR:-/tmp}/wan-snapshot.$(id -u 2>/dev/null || echo 0).lock" fi exec 9>"$_lock" 2>/dev/null || return 0 flock -n 9 || { echo "wan-snapshot: skip (already running, lock=$_lock)" >&2 exit 0 } } snapshot_once() { need_ping acquire_lock _ts="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')" _role="$(detect_role)" _host="$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown)" _line="ts=$_ts ver=2 role=$_role host=$_host" _line="$(append_ping_fields "$_line" "wan" "$WAN_PING_TARGET")" _line="$(append_ping_fields "$_line" "fe" "$FE_PING_TARGET")" if [ -n "$BE_PING_TARGET" ] && [ "$BE_PING_TARGET" != "none" ]; then _line="$(append_ping_fields "$_line" "be" "$BE_PING_TARGET")" fi if [ -n "$ROUTER_PING_TARGET" ] && [ "$ROUTER_PING_TARGET" != "none" ]; then _line="$(append_ping_fields "$_line" "router" "$ROUTER_PING_TARGET")" fi _gw_target="$GW_PING_TARGET" if [ "$_gw_target" = "auto" ]; then _gw_target="$(default_gw)" [ -n "$_gw_target" ] || _gw_target="none" fi if [ -n "$_gw_target" ] && [ "$_gw_target" != "none" ]; then _line="$(append_ping_fields "$_line" "gw" "$_gw_target")" fi if [ "$WAN_SNAPSHOT_EXTRAS" = "1" ]; then _line="$_line load1=$(load_1m) cpu_busy=$(cpu_busy_pct)%" _ct="$(conntrack_pct)" [ -n "$_ct" ] && _line="$_line $_ct" _wdev="$(route_dev "$WAN_PING_TARGET")" _fdev="$(route_dev "$FE_PING_TARGET")" [ -n "$_wdev" ] && _line="$_line route_wan=$_wdev" [ -n "$_fdev" ] && _line="$_line route_fe=$_fdev" _wgw="$(route_gw "$WAN_PING_TARGET")" [ -n "$_wgw" ] && _line="$_line via_wan=$_wgw" _nfe="$(neigh_state "$FE_PING_TARGET")" [ -n "$_nfe" ] && _line="$_line neigh_fe=$_nfe" _drops="$(iface_drops_compact)" [ -n "$_drops" ] && _line="$_line drops=\"$_drops\"" fi _dir="$(dirname "$WAN_SNAPSHOT_LOG")" [ -d "$_dir" ] || mkdir -p "$_dir" 2>/dev/null || true printf '%s\n' "$_line" >> "$WAN_SNAPSHOT_LOG" printf '%s\n' "$_line" } # Extract numeric field from log line (supports wan_avg_ms and legacy layouts). field_value() { _line="$1" _field="$2" printf '%s\n' "$_line" | awk -v f="$_field" ' { for (i = 1; i <= NF; i++) { if ($i ~ "^" f "=") { split($i, a, "=") v = a[2] gsub(/%/, "", v) if (v + 0 == v) { print v; exit } } } } ' } day_stats() { _log="$1" _date="$2" _field="$3" [ -r "$_log" ] || return 0 awk -v d="$_date" -v f="$_field" ' $0 ~ "^ts=" d { for (i = 1; i <= NF; i++) { if ($i ~ "^" f "=") { split($i, a, "=") v = a[2] gsub(/%/, "", v) if (v + 0 == v && v >= 0) { n++; s += v; if (v > max) max = v if (v >= 50) bad++ vals[n] = v } } } } END { if (n == 0) { print "n/a n/a n/a n/a"; exit } asort(vals) p50 = vals[int((n + 1) * 0.5)] p90 = vals[int(n * 0.9)] if (p90 == "") p90 = vals[n] printf "%.2f %.2f %.2f %.0f %d/%d", s / n, p50, p90, max, bad + 0, n } ' "$_log" } day_max_loss() { _log="$1" _date="$2" _field="$3" [ -r "$_log" ] || return 0 awk -v d="$_date" -v f="$_field" ' $0 ~ "^ts=" d { for (i = 1; i <= NF; i++) { if ($i ~ "^" f "=") { split($i, a, "=") v = a[2] gsub(/%/, "", v) if (v + 0 == v && v >= 0 && v > max) max = v } } } END { if (max == "") print "n/a" else printf "%.0f", max } ' "$_log" } report_day_block() { _log="$1" _label="$2" _date="$3" _samples="$(grep -c "^ts=$_date" "$_log" 2>/dev/null || echo 0)" printf '%s=%s samples=%s\n' "$_label" "$_date" "$_samples" for _pfx in wan fe be router gw; do _stats="$(day_stats "$_log" "$_date" "${_pfx}_avg_ms")" _mean="${_stats%% *}" _rest="${_stats#* }" _p50="${_rest%% *}" _rest="${_rest#* }" _p90="${_rest%% *}" _rest="${_rest#* }" _max="${_rest%% *}" _bad="${_rest#* }" _loss="$(day_max_loss "$_log" "$_date" "${_pfx}_loss")" if [ "$_mean" != "n/a" ]; then printf ' %s: mean=%s p50=%s p90=%s max=%s bad(>=50ms)=%s loss_max=%s%%\n' \ "$_pfx" "$_mean" "$_p50" "$_p90" "$_max" "$_bad" "$_loss" fi done } report() { _log="$WAN_SNAPSHOT_LOG" _today="$(date '+%Y-%m-%d')" _yesterday="$(date -d 'yesterday' '+%Y-%m-%d' 2>/dev/null || date -v-1d '+%Y-%m-%d' 2>/dev/null || echo '')" if [ ! -r "$_log" ]; then echo "wan-snapshot: no log at $_log (run default command first)" >&2 exit 1 fi _role="$(detect_role)" printf 'log=%s role=%s\n' "$_log" "$_role" report_day_block "$_log" "today" "$_today" if [ -n "$_yesterday" ] && grep -q "^ts=$_yesterday" "$_log" 2>/dev/null; then report_day_block "$_log" "yesterday" "$_yesterday" fi printf '\nInterpretation:\n' printf ' wan bad + fe good -> ISP/upstream or route_dev issue\n' printf ' wan + fe bad -> router/LAN/tunnel path\n' printf ' fe bad (pi) + fe ok (fe) -> path to FE, not FE host CPU\n' printf ' high mdev / max vs avg -> jitter (bufferbloat, conntrack, VPN)\n' printf ' conntrack>80%% -> edge table pressure (router)\n' printf ' neigh_fe not REACHABLE -> ARP/L2 flap toward FE\n' printf ' ping good, HTTPS slow -> DNAT/nginx above this probe\n' } tail_log() { _n="${1:-24}" [ -r "$WAN_SNAPSHOT_LOG" ] || { echo "wan-snapshot: no log at $WAN_SNAPSHOT_LOG" >&2 exit 1 } tail -n "$_n" "$WAN_SNAPSHOT_LOG" } # Guess role label for correlate when log line has no role=. log_role_label() { _path="$1" _line="$2" _role="$(field_value "$_line" "role")" if [ -n "$_role" ] && [ "$_role" != "0" ]; then printf '%s' "$_role" return 0 fi case "$_path" in *raspberry*|*rpi*) printf 'pi' ;; *FE_gentoo*|*gentoo*) printf 'fe' ;; *monstro*|*router*) printf 'router' ;; *) printf '%s' "$(basename "$_path" .log)" ;; esac } # Bucket timestamp to 15-min wall-clock slot: YYYY-MM-DDTHH:00|15|30|45 slot_key() { _ts="$1" _date="${_ts%%T*}" _rest="${_ts#*T}" _hour="${_rest%%:*}" _min="${_rest#*:}" _min="${_min%%:*}" _min="${_min%%+*}" _min="${_min%%-*}" _bucket=$(( (_min / 15) * 15 )) printf '%sT%s:%02d\n' "$_date" "$_hour" "$_bucket" } correlate_logs() { if [ "$#" -lt 1 ]; then echo "wan-snapshot: --correlate needs at least one log file" >&2 exit 1 fi _tmp="$(mktemp "${TMPDIR:-/tmp}/wan-correlate.XXXXXX")" # shellcheck disable=SC2064 trap 'rm -f "$_tmp"' EXIT INT HUP for _path in "$@"; do [ -r "$_path" ] || { echo "wan-snapshot: unreadable: $_path" >&2 continue } _label="$(basename "$_path")" while IFS= read -r _line || [ -n "$_line" ]; do case "$_line" in ts=*) ;; *) continue ;; esac _ts="${_line#ts=}" _ts="${_ts%% *}" _slot="$(slot_key "$_ts")" _role="$(log_role_label "$_path" "$_line")" printf '%s\t%s\t%s\t%s\t%s\n' "$_slot" "$_role" "$_ts" "$_label" "$_line" >> "$_tmp" done < "$_path" done printf 'slot\t' _roles="$(awk -F'\t' '{print $2}' "$_tmp" | sort -u | tr '\n' ' ')" for _role in $_roles; do printf '%s_wan\t%s_fe\t%s_be\t' "$_role" "$_role" "$_role" done printf '\n' awk -F'\t' ' { slot = $1; role = $2; ts = $3; line = $5 key = slot SUBSEP role if (!(key in best_ts) || ts > best_ts[key]) { best_ts[key] = ts wan = fe = be = "-" for (i = 1; i <= split(line, a, " "); i++) { if (a[i] ~ /^wan_avg_ms=/) { split(a[i], b, "="); wan = b[2] } if (a[i] ~ /^fe_avg_ms=/) { split(a[i], b, "="); fe = b[2] } if (a[i] ~ /^be_avg_ms=/) { split(a[i], b, "="); be = b[2] } } W[slot, role, "wan"] = wan W[slot, role, "fe"] = fe W[slot, role, "be"] = be slots[slot] = 1 roles[role] = 1 } } END { nroles = 0 for (r in roles) rolelist[++nroles] = r asort(rolelist) nslots = 0 for (s in slots) slotlist[++nslots] = s asort(slotlist) for (si = 1; si <= nslots; si++) { s = slotlist[si] printf "%s", s for (ri = 1; ri <= nroles; ri++) { r = rolelist[ri] printf "\t%s\t%s\t%s", W[s, r, "wan"] + 0 ? W[s, r, "wan"] : "-", \ W[s, r, "fe"] + 0 ? W[s, r, "fe"] : "-", \ W[s, r, "be"] + 0 ? W[s, r, "be"] : "-" } printf "\n" } } ' "$_tmp" | while IFS= read -r _row; do printf '%s\n' "$_row" done } case "${1:-}" in -h|--help|help) usage ;; --report|report) report ;; --tail|tail) tail_log "${2:-24}" ;; --correlate|correlate) shift correlate_logs "$@" ;; "") snapshot_once ;; *) echo "wan-snapshot: unknown command: $1 (try --help)" >&2 exit 1 ;; esac