#!/usr/bin/env bash ##################################################################################### #### vpn-smoke-tests.sh — Verify VPN tunnels are healthy #### #### Checks WireGuard and OpenVPN interface, handshake, peers, DNS, routes, MTU. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: ./vpn-smoke-tests.sh #### #### VPN_TYPE=wireguard VPN_INTERFACE=wg0 ./vpn-smoke-tests.sh #### #### #### #### See --help for all options. #### ##################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── VPN_TYPE="${VPN_TYPE:-auto}" VPN_INTERFACE="${VPN_INTERFACE:-}" HANDSHAKE_MAX_SEC="${HANDSHAKE_MAX_SEC:-180}" PING_TARGET="${PING_TARGET:-}" DNS_TEST_DOMAIN="${DNS_TEST_DOMAIN:-}" VPN_DNS_SERVER="${VPN_DNS_SERVER:-}" EXPECTED_ROUTES="${EXPECTED_ROUTES:-}" MGMT_SOCKET="${MGMT_SOCKET:-}" EXPECTED_MTU="${EXPECTED_MTU:-}" VPN_ENDPOINT="${VPN_ENDPOINT:-}" SKIP_HANDSHAKE="${SKIP_HANDSHAKE:-false}" SKIP_DNS="${SKIP_DNS:-false}" SKIP_PING="${SKIP_PING:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" COLOR="${COLOR:-auto}" VERBOSE="${VERBOSE:-false}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 SKIP=0 TOTAL=0 RESULTS=() START_TIME="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" detail="${2:-}" ((PASS++)) || true; ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}${detail:+ (${detail})}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"; fi } record_fail() { local name="$1" detail="${2:-}" ((FAIL++)) || true; ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}"; fi } record_skip() { local name="$1" reason="${2:-}" ((SKIP++)) || true; ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}"; fi } # ── Helpers ─────────────────────────────────────────────────────────── has_cmd() { command -v "$1" >/dev/null 2>&1; } section() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo ""; echo -e "${BOLD}$1${RESET}"; fi } # ── Cleanup ─────────────────────────────────────────────────────────── # shellcheck disable=SC2317 cleanup() { verbose "Cleanup complete." } trap cleanup EXIT # ── VPN Type Detection ─────────────────────────────────────────────── detect_vpn_type() { if [[ "$VPN_TYPE" != "auto" ]]; then verbose "VPN type set to ${VPN_TYPE}" return fi if has_cmd wg && wg show interfaces 2>/dev/null | grep -q .; then VPN_TYPE="wireguard" verbose "Auto-detected WireGuard" elif pgrep -x openvpn >/dev/null 2>&1; then VPN_TYPE="openvpn" verbose "Auto-detected OpenVPN" elif ip link show wg0 >/dev/null 2>&1; then VPN_TYPE="wireguard" verbose "Auto-detected WireGuard (interface wg0 exists)" elif ip link show tun0 >/dev/null 2>&1; then VPN_TYPE="openvpn" verbose "Auto-detected OpenVPN (interface tun0 exists)" else err "Could not auto-detect VPN type. Set VPN_TYPE=wireguard or VPN_TYPE=openvpn." exit 1 fi } set_defaults() { if [[ -z "$VPN_INTERFACE" ]]; then case "$VPN_TYPE" in wireguard) VPN_INTERFACE="wg0" ;; openvpn) VPN_INTERFACE="tun0" ;; esac verbose "Default interface: ${VPN_INTERFACE}" fi } # ══════════════════════════════════════════════════════════════════════ # TEST FUNCTIONS # ══════════════════════════════════════════════════════════════════════ # ── 1. VPN type detection ──────────────────────────────────────────── test_vpn_type() { case "$VPN_TYPE" in wireguard|openvpn) record_pass "VPN type detection" "${VPN_TYPE}" ;; *) record_fail "VPN type detection" "unknown type: ${VPN_TYPE}" ;; esac } # ── 2. Interface up ────────────────────────────────────────────────── test_interface_up() { if ! ip link show "$VPN_INTERFACE" >/dev/null 2>&1; then record_fail "Interface up" "${VPN_INTERFACE} does not exist" return fi local state state=$(ip -o link show "$VPN_INTERFACE" 2>/dev/null | grep -oP 'state \K\S+') || true if [[ "$state" == "UP" || "$state" == "UNKNOWN" ]]; then record_pass "Interface up" "${VPN_INTERFACE} state=${state}" else record_fail "Interface up" "${VPN_INTERFACE} state=${state:-DOWN}" fi } # ── 3. WireGuard handshake ─────────────────────────────────────────── test_wg_handshake() { if [[ "$VPN_TYPE" != "wireguard" ]]; then record_skip "WireGuard handshake" "not WireGuard"; return; fi if [[ "$SKIP_HANDSHAKE" == "true" ]]; then record_skip "WireGuard handshake" "SKIP_HANDSHAKE=true"; return; fi if ! has_cmd wg; then record_skip "WireGuard handshake" "wg not installed"; return; fi local handshakes oldest_age=0 peer_count=0 now now=$(date +%s) handshakes=$(wg show "$VPN_INTERFACE" latest-handshakes 2>/dev/null) || true if [[ -z "$handshakes" ]]; then record_fail "WireGuard handshake" "no handshake data from wg show" return fi while IFS=$'\t' read -r _peer ts; do ((peer_count++)) || true if [[ "$ts" == "0" ]]; then record_fail "WireGuard handshake" "peer has never completed a handshake" return fi local age=$(( now - ts )) if [[ $age -gt $oldest_age ]]; then oldest_age=$age; fi done <<< "$handshakes" if [[ $peer_count -eq 0 ]]; then record_fail "WireGuard handshake" "no peers configured" return fi if [[ $oldest_age -le $HANDSHAKE_MAX_SEC ]]; then record_pass "WireGuard handshake" "${oldest_age}s ago (<= ${HANDSHAKE_MAX_SEC}s), ${peer_count} peer(s)" else record_fail "WireGuard handshake" "${oldest_age}s ago (> ${HANDSHAKE_MAX_SEC}s)" fi } # ── 4. WireGuard peer reachable ────────────────────────────────────── test_wg_peer_reachable() { if [[ "$VPN_TYPE" != "wireguard" ]]; then record_skip "WireGuard peer reachable" "not WireGuard"; return; fi if [[ "$SKIP_PING" == "true" ]]; then record_skip "WireGuard peer reachable" "SKIP_PING=true"; return; fi if ! has_cmd wg; then record_skip "WireGuard peer reachable" "wg not installed"; return; fi local endpoints peer_count=0 reachable=0 endpoints=$(wg show "$VPN_INTERFACE" endpoints 2>/dev/null) || true if [[ -z "$endpoints" ]]; then record_skip "WireGuard peer reachable" "no endpoint data" return fi while IFS=$'\t' read -r _peer endpoint; do [[ "$endpoint" == "(none)" || -z "$endpoint" ]] && continue ((peer_count++)) || true local host="${endpoint%:*}" # Strip brackets from IPv6 host="${host#[}" host="${host%]}" if ping -c 1 -W 3 "$host" >/dev/null 2>&1; then ((reachable++)) || true fi done <<< "$endpoints" if [[ $peer_count -eq 0 ]]; then record_skip "WireGuard peer reachable" "no endpoints configured" elif [[ $reachable -eq $peer_count ]]; then record_pass "WireGuard peer reachable" "${reachable}/${peer_count} endpoints" else record_fail "WireGuard peer reachable" "${reachable}/${peer_count} endpoints reachable" fi } # ── 5. WireGuard transfer ──────────────────────────────────────────── test_wg_transfer() { if [[ "$VPN_TYPE" != "wireguard" ]]; then record_skip "WireGuard transfer" "not WireGuard"; return; fi if ! has_cmd wg; then record_skip "WireGuard transfer" "wg not installed"; return; fi local transfer total_rx=0 total_tx=0 transfer=$(wg show "$VPN_INTERFACE" transfer 2>/dev/null) || true if [[ -z "$transfer" ]]; then record_fail "WireGuard transfer" "no transfer data" return fi while IFS=$'\t' read -r _peer rx tx; do total_rx=$((total_rx + rx)) total_tx=$((total_tx + tx)) done <<< "$transfer" if [[ $total_rx -gt 0 && $total_tx -gt 0 ]]; then local rx_h tx_h rx_h=$(numfmt --to=iec "$total_rx" 2>/dev/null) || rx_h="${total_rx}B" tx_h=$(numfmt --to=iec "$total_tx" 2>/dev/null) || tx_h="${total_tx}B" record_pass "WireGuard transfer" "rx=${rx_h} tx=${tx_h}" elif [[ $total_rx -gt 0 || $total_tx -gt 0 ]]; then record_fail "WireGuard transfer" "one-way traffic only (rx=${total_rx} tx=${total_tx})" else record_fail "WireGuard transfer" "no traffic (rx=0 tx=0)" fi } # ── 6. OpenVPN process ─────────────────────────────────────────────── test_ovpn_process() { if [[ "$VPN_TYPE" != "openvpn" ]]; then record_skip "OpenVPN process" "not OpenVPN"; return; fi local pid pid=$(pgrep -x openvpn 2>/dev/null | head -1) || true if [[ -n "$pid" ]]; then record_pass "OpenVPN process" "PID ${pid}" else record_fail "OpenVPN process" "openvpn not running" fi } # ── 7. OpenVPN management ──────────────────────────────────────────── test_ovpn_management() { if [[ "$VPN_TYPE" != "openvpn" ]]; then record_skip "OpenVPN management" "not OpenVPN"; return; fi if [[ -z "$MGMT_SOCKET" ]]; then record_skip "OpenVPN management" "MGMT_SOCKET not set"; return; fi if [[ -S "$MGMT_SOCKET" ]]; then local status_output status_output=$(echo "status" | socat - "UNIX-CONNECT:${MGMT_SOCKET}" 2>/dev/null) || true if [[ -n "$status_output" ]] && echo "$status_output" | grep -qi "client\|connected\|bytes"; then record_pass "OpenVPN management" "socket responding" elif [[ -n "$status_output" ]]; then record_pass "OpenVPN management" "socket responding" else record_fail "OpenVPN management" "socket not responding" fi elif [[ -e "$MGMT_SOCKET" ]]; then record_fail "OpenVPN management" "${MGMT_SOCKET} exists but is not a socket" else record_fail "OpenVPN management" "${MGMT_SOCKET} not found" fi } # ── 8. Tunnel IP assigned ──────────────────────────────────────────── test_tunnel_ip() { local ip_addr ip_addr=$(ip -o -4 addr show "$VPN_INTERFACE" 2>/dev/null | awk '{print $4}' | head -1) || true if [[ -z "$ip_addr" ]]; then # Try IPv6 ip_addr=$(ip -o -6 addr show "$VPN_INTERFACE" 2>/dev/null | grep -v "fe80" | awk '{print $4}' | head -1) || true fi if [[ -n "$ip_addr" ]]; then record_pass "Tunnel IP assigned" "${VPN_INTERFACE} ${ip_addr}" else record_fail "Tunnel IP assigned" "${VPN_INTERFACE} has no IP address" fi } # ── 9. DNS over VPN ────────────────────────────────────────────────── test_dns_over_vpn() { if [[ "$SKIP_DNS" == "true" ]]; then record_skip "DNS over VPN" "SKIP_DNS=true"; return; fi if [[ -z "$DNS_TEST_DOMAIN" ]]; then record_skip "DNS over VPN" "DNS_TEST_DOMAIN not set"; return; fi if [[ -z "$VPN_DNS_SERVER" ]]; then record_skip "DNS over VPN" "VPN_DNS_SERVER not set"; return; fi local output if has_cmd dig; then output=$(dig +short +time=5 +tries=1 "@${VPN_DNS_SERVER}" "${DNS_TEST_DOMAIN}" A 2>/dev/null) || true elif has_cmd nslookup; then output=$(nslookup "${DNS_TEST_DOMAIN}" "${VPN_DNS_SERVER}" 2>/dev/null | grep -i "address" | tail -1) || true elif has_cmd drill; then output=$(drill "@${VPN_DNS_SERVER}" "${DNS_TEST_DOMAIN}" A 2>/dev/null | grep -A1 "ANSWER SECTION" | tail -1) || true else record_skip "DNS over VPN" "no DNS tool available (dig, nslookup, drill)" return fi if [[ -n "$output" ]] && ! echo "$output" | grep -qi "timed out\|SERVFAIL\|connection refused"; then record_pass "DNS over VPN" "${DNS_TEST_DOMAIN} via ${VPN_DNS_SERVER}" else record_fail "DNS over VPN" "failed to resolve ${DNS_TEST_DOMAIN} via ${VPN_DNS_SERVER}" fi } # ── 10. Route check ───────────────────────────────────────────────── test_route_check() { if [[ -z "$EXPECTED_ROUTES" ]]; then record_skip "Route check" "EXPECTED_ROUTES not set"; return; fi local IFS=',' missing=0 checked=0 for route in $EXPECTED_ROUTES; do route=$(echo "$route" | xargs) [[ -z "$route" ]] && continue ((checked++)) || true if ip route show "$route" 2>/dev/null | grep -q "$VPN_INTERFACE"; then verbose "Route ${route} via ${VPN_INTERFACE} — OK" else verbose "Route ${route} via ${VPN_INTERFACE} — MISSING" ((missing++)) || true fi done if [[ $checked -eq 0 ]]; then record_skip "Route check" "no routes to check" elif [[ $missing -eq 0 ]]; then record_pass "Route check" "${checked} route(s) via ${VPN_INTERFACE}" else record_fail "Route check" "${missing}/${checked} route(s) missing from ${VPN_INTERFACE}" fi } # ── 11. Peer connectivity ─────────────────────────────────────────── test_peer_connectivity() { if [[ "$SKIP_PING" == "true" ]]; then record_skip "Peer connectivity" "SKIP_PING=true"; return; fi if [[ -z "$PING_TARGET" ]]; then record_skip "Peer connectivity" "PING_TARGET not set"; return; fi if ping -c 3 -W 5 -I "$VPN_INTERFACE" "$PING_TARGET" >/dev/null 2>&1; then record_pass "Peer connectivity" "ping ${PING_TARGET} via ${VPN_INTERFACE}" else record_fail "Peer connectivity" "cannot reach ${PING_TARGET} via ${VPN_INTERFACE}" fi } # ── 12. MTU check ──────────────────────────────────────────────────── test_mtu_check() { if [[ -z "$EXPECTED_MTU" ]]; then record_skip "MTU check" "EXPECTED_MTU not set"; return; fi local actual_mtu actual_mtu=$(ip -o link show "$VPN_INTERFACE" 2>/dev/null | grep -oP 'mtu \K[0-9]+') || true if [[ -z "$actual_mtu" ]]; then record_fail "MTU check" "could not read MTU for ${VPN_INTERFACE}" elif [[ "$actual_mtu" == "$EXPECTED_MTU" ]]; then record_pass "MTU check" "${VPN_INTERFACE} MTU=${actual_mtu}" else record_fail "MTU check" "${VPN_INTERFACE} MTU=${actual_mtu} (expected ${EXPECTED_MTU})" fi } # ── 13. Endpoint reachable ─────────────────────────────────────────── test_endpoint_reachable() { if [[ -z "$VPN_ENDPOINT" ]]; then record_skip "Endpoint reachable" "VPN_ENDPOINT not set"; return; fi local host port # Handle host:port format if [[ "$VPN_ENDPOINT" =~ ^\[.*\]:[0-9]+$ ]]; then # IPv6 [addr]:port host="${VPN_ENDPOINT%:*}" host="${host#[}" host="${host%]}" port="${VPN_ENDPOINT##*:}" elif [[ "$VPN_ENDPOINT" =~ ^[^:]+:[0-9]+$ ]]; then host="${VPN_ENDPOINT%:*}" port="${VPN_ENDPOINT##*:}" else host="$VPN_ENDPOINT" port="" fi if [[ -n "$port" ]]; then # Test TCP/UDP reachability if has_cmd nc; then if nc -z -w 5 "$host" "$port" >/dev/null 2>&1; then record_pass "Endpoint reachable" "${VPN_ENDPOINT}" return fi fi # Fall back to ping if nc fails or unavailable if ping -c 1 -W 5 "$host" >/dev/null 2>&1; then record_pass "Endpoint reachable" "${host} (ping OK, port ${port} not tested)" else record_fail "Endpoint reachable" "${VPN_ENDPOINT} unreachable" fi else if ping -c 1 -W 5 "$host" >/dev/null 2>&1; then record_pass "Endpoint reachable" "${host}" else record_fail "Endpoint reachable" "${host} unreachable" fi fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } print_summary() { local end_time; end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} VPN Smoke Tests ${VPN_TYPE}/${VPN_INTERFACE}" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}"; fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <