#!/usr/bin/env bash # # Orynium netcheck.sh # Read-only network / connectivity / VPN-tunnel inspection for Linux servers. # Works on Debian/Ubuntu (apt) and RHEL/CentOS/Fedora/Rocky/Alma (dnf/yum), # with best-effort support for other distros (SUSE/zypper, Alpine/apk). # # GUARANTEE: this script does not change firewall rules, network config, # services, or installed packages. It only reads state and, where explicitly # confirmed, refreshes the local package-index cache (apt update / dnf # makecache) purely to test repo reachability - that step can be skipped. # # Usage: # bash netcheck.sh interactive # bash netcheck.sh --domain=example.com --yes non-interactive, sane defaults # bash netcheck.sh --help # set -uo pipefail # --------------------------------------------------------------------------- # Setup / globals # --------------------------------------------------------------------------- SCRIPT_VERSION="2.0" TS="$(date +%Y%m%d-%H%M%S)" LOGFILE="${TMPDIR:-/tmp}/orynium-netcheck-${TS}.log" # The report can contain IPs, proxy settings and panel errors. Create it # private (owner-only) BEFORE writing anything to it. ( umask 077; : > "$LOGFILE" ) 2>/dev/null || : > "$LOGFILE" chmod 600 "$LOGFILE" 2>/dev/null || true DOMAIN="" NONINTERACTIVE=0 INPUT_COUNT=0 SKIP_ALL_INPUTS=0 MAX_PROMPTED_BEFORE_SKIP_OPTION=5 OK_ITEMS=() WARN_ITEMS=() ISSUE_ITEMS=() # ---- Orynium palette (cyan / steel on deep blue-black) ---------------------- if [[ -t 1 ]] && [[ "${NO_COLOR:-}" == "" ]]; then C_CYN=$'\e[38;5;80m' # primary accent C_STL=$'\e[38;5;69m' # steel blue C_GRN=$'\e[38;5;79m' # ok C_YEL=$'\e[38;5;179m' # warn C_RED=$'\e[38;5;203m' # issue C_DIM=$'\e[38;5;245m' # secondary text C_FNT=$'\e[38;5;240m' # faint rules C_BLD=$'\e[1m'; C_NC=$'\e[0m' else C_CYN=""; C_STL=""; C_GRN=""; C_YEL=""; C_RED=""; C_DIM=""; C_FNT=""; C_BLD=""; C_NC="" fi # say() -> screen + log log_only() -> log file only (keeps screen clean) say() { printf '%b\n' "$*"; printf '%b\n' "$*" | sed 's/\x1b\[[0-9;]*m//g' >> "$LOGFILE"; } log_only() { printf '%b\n' "$*" | sed 's/\x1b\[[0-9;]*m//g' >> "$LOGFILE"; } detail() { # long output: full text to the log, only the first N lines on screen local n="${2:-6}" printf '%s\n' "$1" >> "$LOGFILE" printf '%s\n' "$1" | head -"$n" | while IFS= read -r l; do printf ' %b%s%b\n' "$C_DIM" "$l" "$C_NC" done } SECTION_N=0 section() { SECTION_N=$((SECTION_N+1)) say "" say "${C_FNT}────────────────────────────────────────────────────────────${C_NC}" say "${C_CYN}${C_BLD} ▸ $(printf '%02d' $SECTION_N) $*${C_NC}" } mark_ok() { OK_ITEMS+=("$1"); say " ${C_GRN}✔${C_NC} $1"; } mark_warn() { WARN_ITEMS+=("$1"); say " ${C_YEL}▲${C_NC} $1"; } mark_issue() { ISSUE_ITEMS+=("$1"); say " ${C_RED}✖${C_NC} $1"; } info() { say " ${C_DIM}$1${C_NC}"; } have_cmd() { command -v "$1" >/dev/null 2>&1; } run_q() { timeout "${2:-5}" bash -c "$1" 2>>"$LOGFILE"; } # ---- 3-way verification ---------------------------------------------------- # Runs up to three independent probes for the same question and reports how # many agreed. A single failing tool is a tool problem; three failing tools # is a real problem. Everything here is read-only. # verify3 "label" "cmd1" "cmd2" "cmd3" # Sets: V3_PASS (count that succeeded), V3_TOTAL (count actually runnable) verify3() { local label="$1"; shift V3_PASS=0; V3_TOTAL=0; V3_DETAIL="" local i=1 for c in "$@"; do [[ -z "$c" ]] && { i=$((i+1)); continue; } # skip probes whose base command is not installed local base; base=$(echo "$c" | awk '{print $1}') if ! command -v "$base" >/dev/null 2>&1; then V3_DETAIL+=" probe $i: ${base} not installed - skipped"$'\n' i=$((i+1)); continue fi V3_TOTAL=$((V3_TOTAL+1)) if timeout 12 bash -c "$c" >/dev/null 2>>"$LOGFILE"; then V3_PASS=$((V3_PASS+1)); V3_DETAIL+=" probe $i ok : $c"$'\n' else V3_DETAIL+=" probe $i FAILED: $c"$'\n' fi i=$((i+1)) done log_only " [3-way] $label -> $V3_PASS/$V3_TOTAL passed" log_only "$V3_DETAIL" } for arg in "$@"; do case "$arg" in --domain=*) DOMAIN="${arg#*=}" ;; --yes|--non-interactive) NONINTERACTIVE=1 ;; --offline) OFFLINE=1 ;; --help|-h) echo "Usage: $0 [--domain=example.com] [--yes] [--help]" echo " --domain=X set the domain for web/DNS/TLS checks (skips the prompt)" echo " --yes skip every optional prompt and use defaults (for automation)" echo " --offline no internet on this box: skip all external lookups and" echo " run only local checks (NICs, MTU, routes, firewall, VPN," echo " proxy, SSH policy, panel logs). Nothing leaves the server." exit 0 ;; esac done # --------------------------------------------------------------------------- # Input helper: honors a 5-question soft limit; after that, user can bail # out of all remaining prompts in one keystroke. --yes / non-tty auto-skips. # --------------------------------------------------------------------------- ask() { # ask "question text" "default_value" -> echoes chosen value local question="$1" default="${2:-}" INPUT_COUNT=$((INPUT_COUNT + 1)) if [[ $NONINTERACTIVE -eq 1 || ! -t 0 || $SKIP_ALL_INPUTS -eq 1 ]]; then echo "$default" return fi local suffix=" [default: ${default:-none}]" if [[ $INPUT_COUNT -gt $MAX_PROMPTED_BEFORE_SKIP_OPTION ]]; then suffix="$suffix (or type 'skip-rest' to accept defaults for everything left)" fi read -r -p "$question$suffix: " reply if [[ "$reply" == "skip-rest" ]]; then SKIP_ALL_INPUTS=1 echo "$default" return fi echo "${reply:-$default}" } ask_yn() { # ask_yn "question" "y|n default" -> returns 0 for yes, 1 for no local question="$1" default="${2:-y}" local ans ans="$(ask "$question (y/n)" "$default")" [[ "$ans" =~ ^[Yy] ]] } # --------------------------------------------------------------------------- # Banner # --------------------------------------------------------------------------- say "${C_BLD}Orynium netcheck.sh v${SCRIPT_VERSION}${C_NC}" say "Read-only network diagnostic. Log: $LOGFILE" say "Started: $(date)" # --------------------------------------------------------------------------- # 0. Interactive intake # --------------------------------------------------------------------------- if [[ -z "$DOMAIN" ]]; then DOMAIN="$(ask "Enter a domain to include in web/DNS/TLS checks (leave blank to skip)" "")" fi RUN_VPN_SCAN=1 RUN_REPO_TEST=1 RUN_TLS=1 PING_TARGET="1.1.1.1" say "" say " You'll be asked up to 10 short questions. Press Enter to accept the" say " default on any of them, or type 'skip-rest' to auto-fill everything left." say "" : "${OFFLINE:=0}" [[ "$OFFLINE" -eq 1 ]] && NONINTERACTIVE=1 if ask_yn "Run VPN / tunnel / proxy detection" "y"; then RUN_VPN_SCAN=1; else RUN_VPN_SCAN=0; fi if ask_yn "Include HISTORICAL evidence (shell history, package logs, journal, installer leftovers)" "y"; then RUN_HISTORY=1; else RUN_HISTORY=0; fi if ask_yn "Check for configured proxies (env vars, apt/yum/docker/git proxy settings)" "y"; then RUN_PROXY=1; else RUN_PROXY=0; fi if ask_yn "Test package-repository reachability (refreshes local apt/dnf cache only)" "y"; then RUN_REPO_TEST=1; else RUN_REPO_TEST=0; fi if ask_yn "Run git / repository connectivity diagnostics (git, curl, TLS backend)" "y"; then RUN_GIT=1; else RUN_GIT=0; fi if ask_yn "Read control-panel logs (DirectAdmin / cPanel / Plesk / aaPanel) for errors" "y"; then RUN_PANEL=1; else RUN_PANEL=0; fi PING_TARGET="$(ask "External IP to use for ping/MTU tests" "1.1.1.1")" if ask_yn "Allow external lookups? (sends this server's public IP to icanhazip.com and ipinfo.io to detect egress IP + country)" "y"; then ALLOW_EXTERNAL=1; else ALLOW_EXTERNAL=0; fi EXPECTED_GEO="$(ask "Expected country code for this server's public IP (e.g. US, IT, IR, FR, IN - blank to skip geo check)" "")" GIT_REPO="$(ask "Git repo URL to test cloning/reachability (blank = use github.com)" "")" if [[ -n "$DOMAIN" ]]; then if ask_yn "Run TLS/certificate handshake check against $DOMAIN" "y"; then RUN_TLS=1; else RUN_TLS=0; fi fi : "${RUN_HISTORY:=1}"; : "${RUN_PROXY:=1}"; : "${RUN_GIT:=1}"; : "${RUN_PANEL:=1}" # Offline mode overrides every answer above - applied last so nothing re-enables # a network call. Local checks are unaffected. if [[ "$OFFLINE" -eq 1 ]]; then ALLOW_EXTERNAL=0; RUN_REPO_TEST=0; RUN_GIT=0; RUN_TLS=0 DOMAIN=""; EXPECTED_GEO="" say "" say " ${C_STL}OFFLINE MODE${C_NC}" say " ${C_DIM}External lookups, repo tests, git and domain checks are disabled.${C_NC}" say " ${C_DIM}Only local on-box checks run. Nothing leaves this server.${C_NC}" fi # --------------------------------------------------------------------------- # 1. System / distro detection # --------------------------------------------------------------------------- section "System" OS_NAME="unknown"; OS_VERSION="unknown"; PKG_MGR="unknown" if [[ -f /etc/os-release ]]; then . /etc/os-release OS_NAME="${PRETTY_NAME:-${NAME:-unknown}}" OS_VERSION="${VERSION_ID:-unknown}" fi if have_cmd apt-get; then PKG_MGR="apt" elif have_cmd dnf; then PKG_MGR="dnf" elif have_cmd yum; then PKG_MGR="yum" elif have_cmd zypper; then PKG_MGR="zypper" elif have_cmd apk; then PKG_MGR="apk" fi say " OS: $OS_NAME ($OS_VERSION)" say " Kernel: $(uname -r)" say " Package manager: $PKG_MGR" mark_ok "Detected OS=$OS_NAME pkg-mgr=$PKG_MGR" # --------------------------------------------------------------------------- # 2. Interfaces # --------------------------------------------------------------------------- section "Network interfaces / NICs" if have_cmd ip; then say "$(ip -br addr 2>>"$LOGFILE")" UP_IFACES=$(ip -br link | awk '$2=="UP"{print $1}' | grep -v '^lo$' | wc -l) if [[ "$UP_IFACES" -ge 1 ]]; then mark_ok "$UP_IFACES non-loopback interface(s) UP" else mark_issue "No non-loopback interface is UP" fi # Physical NIC hardware detail (driver, speed, link) - all read-only say " Physical network cards:" for IFACE in $(ls /sys/class/net 2>/dev/null); do [[ "$IFACE" == "lo" ]] && continue # skip obvious virtual/tunnel devices when reporting "cards" DRIVER=""; SPEED=""; MAC="" [[ -e "/sys/class/net/$IFACE/device" ]] && IS_PHYS="physical" || IS_PHYS="virtual" if have_cmd ethtool; then DRIVER=$(ethtool -i "$IFACE" 2>/dev/null | awk -F': ' '/^driver/{print $2}') SPEED=$(ethtool "$IFACE" 2>/dev/null | awk -F': ' '/Speed/{print $2}') fi MAC=$(cat "/sys/class/net/$IFACE/address" 2>/dev/null) STATE=$(cat "/sys/class/net/$IFACE/operstate" 2>/dev/null) say " - $IFACE [$IS_PHYS] state=$STATE driver=${DRIVER:-?} speed=${SPEED:-?} mac=${MAC:-?}" done have_cmd ethtool || mark_warn "ethtool not installed - NIC driver/speed detail unavailable (read-only, install optional)" # ---- MTU check: flag any UP non-loopback, non-tunnel NIC whose MTU != 1500 ---- say " MTU per interface (standard Ethernet = 1500):" MTU_PROBLEM=0 while read -r IFACE; do [[ -z "$IFACE" || "$IFACE" == "lo" ]] && continue MTU=$(cat "/sys/class/net/$IFACE/mtu" 2>/dev/null) # tunnels/bridges legitimately differ; only hard-flag physical-ish ifaces if echo "$IFACE" | grep -Eq '^(tun|tap|wg|ppp|gre|docker|br-|veth|virbr|zt)'; then say " - $IFACE: MTU=$MTU (tunnel/virtual - different MTU is normal)" elif [[ "$MTU" == "1500" ]]; then say " - $IFACE: MTU=$MTU [OK]" else say " - $IFACE: MTU=$MTU [!= 1500]" MTU_PROBLEM=1 fi done < <(ip -br link | awk '$2=="UP"{print $1}') if [[ "$MTU_PROBLEM" -eq 1 ]]; then mark_warn "One or more physical interfaces have MTU != 1500 - can cause fragmentation/hangs; confirm it's intentional (jumbo frames / provider requirement)" else mark_ok "All physical interfaces are at the standard 1500 MTU" fi else mark_warn "'ip' command not found - cannot inspect interfaces" fi # --------------------------------------------------------------------------- # 3. Routing / DNS config # --------------------------------------------------------------------------- section "Routing" if have_cmd ip; then ip route 2>>"$LOGFILE" | tee -a "$LOGFILE" if ip route | grep -q '^default'; then mark_ok "Default route present" else mark_issue "No default route configured" fi ip rule 2>>"$LOGFILE" | grep -qv '^0:\|^32766:\|^32767:' && \ say " Non-default policy routing rules present (see log)" fi # --------------------------------------------------------------------------- # 3b. Primary IP + gateway locality ("is the main IP in the same subnet as # the gateway?"). All read-only. # --------------------------------------------------------------------------- section "Primary IP / gateway locality" if have_cmd ip; then # the interface + source IP the kernel would use to reach the internet DEFAULT_DEV=$(ip route show default 2>>"$LOGFILE" | awk '/default/{print $5; exit}') GATEWAY=$(ip route show default 2>>"$LOGFILE" | awk '/default/{print $3; exit}') PRIMARY_IP=$(ip route get 1.1.1.1 2>>"$LOGFILE" | awk '/src/{for(i=1;i<=NF;i++)if($i=="src")print $(i+1); exit}') PRIMARY_CIDR=$(ip -o -f inet addr show dev "$DEFAULT_DEV" 2>>"$LOGFILE" | awk '{print $4; exit}') say " Primary interface: ${DEFAULT_DEV:-?}" say " Primary (source) IP: ${PRIMARY_IP:-?}" say " Primary IP CIDR: ${PRIMARY_CIDR:-?}" say " Default gateway: ${GATEWAY:-?}" if [[ -n "$PRIMARY_IP" ]]; then # classify internal vs public if echo "$PRIMARY_IP" | grep -Eq '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|169\.254\.)'; then mark_warn "Primary IP $PRIMARY_IP is a PRIVATE/internal address - server is behind NAT (public IP is elsewhere, e.g. the provider's edge)" else mark_ok "Primary IP $PRIMARY_IP is a public address bound directly to this host" fi fi # same-subnet-as-gateway test: is the gateway inside the primary IP's CIDR? if [[ -n "$GATEWAY" && -n "$PRIMARY_CIDR" ]]; then SAME_SUBNET=$(python3 - "$GATEWAY" "$PRIMARY_CIDR" 2>/dev/null <<'PY' import sys, ipaddress try: gw = ipaddress.ip_address(sys.argv[1]) net = ipaddress.ip_network(sys.argv[2], strict=False) print("yes" if gw in net else "no") except Exception: print("unknown") PY ) case "$SAME_SUBNET" in yes) mark_ok "Gateway $GATEWAY is in the same subnet as the primary IP ($PRIMARY_CIDR) - normal L2-adjacent setup" ;; no) mark_warn "Gateway $GATEWAY is OUTSIDE the primary IP's subnet ($PRIMARY_CIDR) - unusual; often means a point-to-point / on-link route or a misconfigured netmask" ;; *) say " (could not compute subnet relationship - python3/ipaddress unavailable)" ;; esac fi fi section "DNS configuration" if [[ -f /etc/resolv.conf ]]; then say "$(cat /etc/resolv.conf 2>>"$LOGFILE")" if grep -q '^nameserver' /etc/resolv.conf; then mark_ok "resolv.conf has at least one nameserver" else mark_issue "resolv.conf has no nameserver entries" fi else mark_warn "/etc/resolv.conf not found" fi # --------------------------------------------------------------------------- # 4. Connectivity (ICMP + TCP, since ICMP is often filtered) # --------------------------------------------------------------------------- section "Connectivity (each result confirmed 3 ways)" if [[ "${OFFLINE:-0}" -eq 1 ]]; then info "skipped - offline mode" else # --- ICMP: ping, then two independent fallbacks ----------------------------- verify3 "ICMP to $PING_TARGET" \ "ping -c2 -W2 $PING_TARGET" \ "ping -c1 -W3 8.8.8.8" \ "ping -c1 -W3 9.9.9.9" if [[ $V3_TOTAL -eq 0 ]]; then info "ping not installed - ICMP not tested" elif [[ $V3_PASS -eq $V3_TOTAL ]]; then mark_ok "ICMP works ($V3_PASS/$V3_TOTAL targets replied)" elif [[ $V3_PASS -gt 0 ]]; then mark_warn "ICMP partially working ($V3_PASS/$V3_TOTAL replied) - one upstream may be filtering, not a local fault" else mark_warn "No ICMP replies ($V3_TOTAL targets tried) - commonly firewalled; check TCP result below before concluding" fi # --- Outbound TCP/443: curl, wget and a raw bash socket --------------------- verify3 "outbound HTTPS" \ "curl -s -o /dev/null --max-time 6 https://1.1.1.1" \ "wget -q -O /dev/null --timeout=6 https://1.1.1.1" \ "timeout 6 bash -c '/dev/null; then MTU_OK=$size; break fi done if [[ -n "$MTU_OK" ]]; then mark_ok "Largest unfragmented ICMP payload to $PING_TARGET: ${MTU_OK} bytes (path MTU ~$((MTU_OK+28)))" else mark_warn "Could not confirm path MTU (ICMP may be filtered)" fi fi # --------------------------------------------------------------------------- # 6. Local network stack limits # --------------------------------------------------------------------------- section "File descriptors / kernel limits" say " ulimit -n (soft): $(ulimit -Sn 2>/dev/null)" say " fs.file-max: $(cat /proc/sys/fs/file-max 2>/dev/null)" say " net.ipv4.ip_forward: $(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" if [[ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" == "1" ]]; then mark_warn "IP forwarding is enabled - expected for routers/VPN gateways/containers, worth confirming it's intentional" fi # --------------------------------------------------------------------------- # 7. Firewall (read-only listing) # --------------------------------------------------------------------------- section "Firewall (state + rule summary, listing only)" FW_ACTIVE="none detected" if have_cmd ufw; then UFW_STATE=$(ufw status 2>>"$LOGFILE" | head -1) say " ufw: $UFW_STATE" echo "$UFW_STATE" | grep -qi "active" && FW_ACTIVE="ufw" fi if have_cmd firewall-cmd; then FWD_STATE=$(firewall-cmd --state 2>>"$LOGFILE") say " firewalld: $FWD_STATE" [[ "$FWD_STATE" == "running" ]] && FW_ACTIVE="firewalld" fi if have_cmd nft; then NFT_RULES=$(nft list ruleset 2>>"$LOGFILE" | grep -cE '^\s*(tcp|udp|ip|ct|meta|iif|oif)' ) say " nftables: $NFT_RULES rule line(s)" [[ "$NFT_RULES" -gt 0 && "$FW_ACTIVE" == "none detected" ]] && FW_ACTIVE="nftables" say "$(nft list ruleset 2>>"$LOGFILE" | head -40)" elif have_cmd iptables; then IPT_RULES=$(iptables -S 2>>"$LOGFILE" | grep -vcE '^-P|^#') say " iptables: $IPT_RULES explicit rule(s)" [[ "$IPT_RULES" -gt 0 && "$FW_ACTIVE" == "none detected" ]] && FW_ACTIVE="iptables" say "$(iptables -L -n -v 2>>"$LOGFILE" | head -40)" fi if [[ "$FW_ACTIVE" == "none detected" ]]; then mark_warn "No active host firewall detected (ufw/firewalld/nft/iptables all empty or inactive) - server may be relying solely on an upstream/provider firewall" else mark_ok "Active firewall: $FW_ACTIVE" fi # --------------------------------------------------------------------------- # 8. Listening services # --------------------------------------------------------------------------- section "Listening ports" if have_cmd ss; then say "$(ss -tulpn 2>>"$LOGFILE")" elif have_cmd netstat; then say "$(netstat -tulpn 2>>"$LOGFILE")" else mark_warn "Neither 'ss' nor 'netstat' available" fi # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # 9. VPN / tunnel / proxy detection (read-only, evidence based) # Looks at: interfaces, systemd units (active AND disabled), processes, # binaries, known config paths, packages, containers, persistence # (cron/rc.local/autostart), and - if enabled - historical evidence from # shell history, package-manager logs, journal and installer leftovers. # --------------------------------------------------------------------------- if [[ "$RUN_VPN_SCAN" -eq 1 ]]; then section "VPN / tunnel / proxy detection" # Specific product names only. Generic words ("vpn", "proxy", "tunnel", # "client", "server") are deliberately NOT used alone - they cause false hits. TOOL_RX='(xray|xrayr|v2ray|v2raya|v2ray-core|v2ray-plugin|v2ray-agent|sing-box|singbox|x-ui|3x-ui|x-ui-next|xray-ui|s-ui|v2board|soga|remnawave|marzban|marzneshin|hiddify|hiddify-manager|hiddify-core|wireguard|wireguard-tools|wg-quick|wg-easy|wgcf|boringtun|amneziawg|openvpn|openvpn3|hysteria|hysteria2|tuic|trojan|trojan-go|trojan-r|shadowsocks|shadowsocks-libev|shadowsocks-rust|ss-server|ss-local|ssserver|sslocal|gost|brook|naiveproxy|reality|cloak|softether|vpnserver|vpncmd|strongswan|swanctl|charon|libreswan|pluto|ipsec|openconnect|openfortivpn|fortivpn|ocserv|pptpd|xl2tpd|sstp|sstpc|sstp-client|badvpn|udp2raw|kcptun|frps|frpc|chisel|zerotier|zerotier-one|zerotier-cli|tailscale|tailscaled|headscale|nebula|netmaker|netclient|netbird|outline-ss-server|shadowbox|mtg|mtproto|mtproto-proxy|mtproxy|mieru|juicity|cloudflared|warp-svc|warp-cli|rathole|shadow-tls|shadowtls|wireproxy|amnezia|easytier-core|easytier-cli|tincd|tinc|innernet|wesher|wstunnel|sshuttle|autossh|ngrok|zrok|inlets|ligolo-proxy|ligolo-agent|n2n-edge|supernode|anytls-server|anytls-client|dnstt|slipstream|nps|npc|iodine)' # word-boundary wrapper that works with plain grep -E everywhere TOOL_BRX="(^|[^[:alnum:]_-])${TOOL_RX}([^[:alnum:]_-]|\$)" KNOWN_CONFIG_PATHS="/etc/xray /usr/local/etc/xray /opt/xray /etc/v2ray /usr/local/etc/v2ray /opt/v2ray /etc/sing-box /usr/local/etc/sing-box /etc/x-ui /usr/local/x-ui /opt/x-ui /opt/3x-ui /opt/marzban /var/lib/marzban /etc/marzban /opt/hiddify-manager /etc/hiddify /etc/wireguard /etc/openvpn /etc/ipsec.conf /etc/ipsec.secrets /etc/strongswan.conf /etc/strongswan.d /etc/swanctl /etc/ocserv /etc/xl2tpd /etc/hysteria /opt/hysteria /etc/tuic /etc/trojan /opt/trojan /etc/shadowsocks-libev /etc/shadowsocks-rust /etc/shadowsocks /etc/gost /etc/zerotier-one /var/lib/zerotier-one /etc/tailscale /var/lib/tailscale /etc/nebula /etc/cloudflared /root/.cloudflared /etc/wireproxy /etc/headscale /etc/netmaker /etc/netclient /etc/netbird /etc/easytier /etc/tinc /etc/s-ui /etc/xrayr /etc/soga /etc/dnstt /etc/iodine" ACTIVE_EV=(); INSTALLED_EV=(); HIST_EV=() # ---- tunnel interfaces (always checked) ---- TUN_IFACES=$(ip link 2>>"$LOGFILE" | grep -Eio '(^|: )(wg[0-9a-z._-]*|tun[0-9a-z._-]*|tap[0-9a-z._-]*|ppp[0-9a-z._-]*|gre[0-9a-z._-]*|gretap[0-9a-z._-]*|ipip[0-9a-z._-]*|tailscale[0-9a-z._-]*|zt[0-9a-z._-]*|nebula[0-9a-z._-]*)' | sed 's/^: //' | sort -u || true) if [[ -n "$TUN_IFACES" ]]; then say " Tunnel-type interfaces:" while read -r t; do [[ -n "$t" ]] && say " - $t"; done <<< "$TUN_IFACES" ACTIVE_EV+=("tunnel interfaces: $(echo "$TUN_IFACES" | tr '\n' ' ')") else say " Tunnel-type interfaces: none" fi # ---- systemd units: active AND installed-but-disabled ---- if have_cmd systemctl; then ACT_UNITS=$(systemctl list-units --type=service --state=running --no-legend --no-pager 2>>"$LOGFILE" \ | awk '{print $1}' | grep -Ei "$TOOL_BRX" | sort -u || true) ALL_UNITS=$(systemctl list-unit-files --type=service --no-legend --no-pager 2>>"$LOGFILE" \ | awk '{print $1}' | grep -Ei "$TOOL_BRX" | sort -u || true) INACT_UNITS=$(comm -13 <(echo "$ACT_UNITS") <(echo "$ALL_UNITS") 2>/dev/null || true) if [[ -n "$ACT_UNITS" ]]; then say " Active VPN/proxy services:" while read -r u; do [[ -n "$u" ]] && say " - $u"; done <<< "$ACT_UNITS" ACTIVE_EV+=("running services: $(echo "$ACT_UNITS" | tr '\n' ' ')") fi if [[ -n "$INACT_UNITS" ]]; then say " Installed but NOT running (disabled/stopped) units:" while read -r u; do [[ -n "$u" ]] && say " - $u"; done <<< "$INACT_UNITS" INSTALLED_EV+=("installed units: $(echo "$INACT_UNITS" | tr '\n' ' ')") fi fi # ---- processes ---- PROC_HITS=$(ps -eo pid=,user=,comm=,args= 2>>"$LOGFILE" \ | grep -Ei "$TOOL_BRX" \ | grep -Ev 'netcheck|vpn-scanner|grep -|egrep' | head -40 || true) if [[ -n "$PROC_HITS" ]]; then say " Matching running processes:" say "$PROC_HITS" ACTIVE_EV+=("running processes matching VPN/proxy tooling") fi # ---- binaries on disk ---- BIN_HITS="" for d in /usr/bin /usr/sbin /usr/local/bin /usr/local/sbin /opt; do [[ -d "$d" ]] || continue F=$(find "$d" -maxdepth 3 \( -type f -o -type l \) -perm /111 2>/dev/null \ | while read -r f; do b="${f##*/}"; echo "$b" | grep -Eqi "^${TOOL_RX}\$" && echo "$f"; done) [[ -n "$F" ]] && BIN_HITS+="$F"$'\n' done BIN_HITS=$(echo "$BIN_HITS" | sed '/^$/d' | sort -u | head -30) if [[ -n "$BIN_HITS" ]]; then say " VPN/proxy binaries present on disk:" say "$BIN_HITS" INSTALLED_EV+=("binaries: $(echo "$BIN_HITS" | tr '\n' ' ')") fi # ---- known config locations ---- CFG_FOUND="" for p in $KNOWN_CONFIG_PATHS; do [[ -e "$p" ]] && CFG_FOUND+="$p"$'\n' done CFG_FOUND=$(echo "$CFG_FOUND" | sed '/^$/d') if [[ -n "$CFG_FOUND" ]]; then say " Known VPN/proxy config locations present:" say "$CFG_FOUND" INSTALLED_EV+=("config paths: $(echo "$CFG_FOUND" | tr '\n' ' ')") fi # ---- packages ---- PKG_HITS="" if have_cmd dpkg-query; then PKG_HITS=$(dpkg-query -W -f='${binary:Package} ${Version}\n' 2>/dev/null | grep -Ei "$TOOL_BRX" | head -25 || true) elif have_cmd rpm; then PKG_HITS=$(rpm -qa 2>/dev/null | grep -Ei "$TOOL_BRX" | head -25 || true) elif have_cmd apk; then PKG_HITS=$(apk info 2>/dev/null | grep -Ei "$TOOL_BRX" | head -25 || true) fi if [[ -n "$PKG_HITS" ]]; then say " Installed packages matching VPN/proxy tooling:" say "$PKG_HITS" INSTALLED_EV+=("packages installed") fi # ---- containers (running and stopped) ---- for rt in docker podman; do have_cmd "$rt" || continue C_ALL=$($rt ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}' 2>/dev/null | grep -Ei "$TOOL_BRX" || true) if [[ -n "$C_ALL" ]]; then say " $rt containers matching VPN/proxy tooling:" say "$C_ALL" if echo "$C_ALL" | grep -qi '|Up'; then ACTIVE_EV+=("$rt containers running"); else INSTALLED_EV+=("$rt containers present (stopped)"); fi fi done # ---- persistence: cron, rc.local, autostart ---- PERSIST="" for f in /etc/crontab /etc/rc.local /etc/rc.d/rc.local /etc/cron.d/* /var/spool/cron/* /var/spool/cron/crontabs/*; do [[ -r "$f" && -f "$f" ]] || continue L=$(grep -Ei "$TOOL_BRX" "$f" 2>/dev/null | grep -v '^\s*#' | head -5) [[ -n "$L" ]] && PERSIST+="$f: $L"$'\n' done if [[ -n "$PERSIST" ]]; then say " Persistence entries (cron / rc.local):" say "$PERSIST" INSTALLED_EV+=("cron/startup persistence entries") fi # ---- listening ports commonly used by these tools ---- if have_cmd ss; then PORT_HITS=$(ss -tulpn 2>>"$LOGFILE" | grep -E ':(1194|51820|51821|1701|500|4500|1080|7890|7891|8388|8389|2408|9000|10085|62789)\b' || true) if [[ -n "$PORT_HITS" ]]; then say " Listening on ports commonly used by VPN/proxy tools:" say "$PORT_HITS" ACTIVE_EV+=("listeners on known VPN/proxy ports") fi fi # ---- HISTORICAL evidence (optional) ---- if [[ "$RUN_HISTORY" -eq 1 ]]; then say "" say " ${C_BLD}Historical evidence (past installs / removals / runs):${C_NC}" # shell history of all users HIST_HITS="" for h in /root/.bash_history /root/.zsh_history /home/*/.bash_history /home/*/.zsh_history; do [[ -r "$h" ]] || continue L=$(grep -Ei "$TOOL_BRX" "$h" 2>/dev/null | grep -Ev 'netcheck|vpn-scanner' | tail -8 | cut -c1-200) [[ -n "$L" ]] && HIST_HITS+="--- $h"$'\n'"$L"$'\n' done if [[ -n "$HIST_HITS" ]]; then say "$HIST_HITS" HIST_EV+=("shell history shows VPN/proxy commands") else say " shell history: nothing matching" fi # package manager logs PKGLOG_HITS="" for f in /var/log/apt/history.log /var/log/dpkg.log /var/log/yum.log /var/log/dnf.log /var/log/pacman.log; do [[ -r "$f" ]] || continue L=$(grep -Ei "$TOOL_BRX" "$f" 2>/dev/null | tail -6 | cut -c1-200) [[ -n "$L" ]] && PKGLOG_HITS+="--- $f"$'\n'"$L"$'\n' done if [[ -n "$PKGLOG_HITS" ]]; then say "$PKGLOG_HITS" HIST_EV+=("package logs show VPN/proxy install or removal") else say " package-manager logs: nothing matching" fi # journal if have_cmd journalctl; then J=$(journalctl --no-pager -n 5000 2>/dev/null | grep -Ei "$TOOL_BRX" | grep -Ev 'netcheck' | tail -6 | cut -c1-200 || true) if [[ -n "$J" ]]; then say " journal:"; say "$J"; HIST_EV+=("system journal references VPN/proxy tooling"); fi fi # installer leftovers ART=$(timeout 8 find /root /home /opt /usr/local/src -xdev -maxdepth 3 -type f \ \( -iname '*xray*' -o -iname '*v2ray*' -o -iname '*sing-box*' -o -iname '*wireguard*' \ -o -iname '*openvpn*' -o -iname '*hysteria*' -o -iname '*trojan*' -o -iname '*shadowsocks*' \ -o -iname '*x-ui*' -o -iname '*marzban*' -o -iname '*hiddify*' -o -iname '*tailscale*' \ -o -iname '*zerotier*' -o -iname '*cloudflared*' -o -iname '*warp*' \) 2>/dev/null | head -15 || true) if [[ -n "$ART" ]]; then say " installer/download leftovers:" say "$ART" HIST_EV+=("installer artifacts on disk") fi fi # ---- SSH as a tunnel: sshd_config policy + live SSH tunnels ---- # SSH is the most overlooked VPN on a server. With forwarding enabled anyone # with an account can build a SOCKS proxy or reverse tunnel, no software # installed at all. This is READ ONLY - nothing is modified. SSHD_CFG="" for c in /etc/ssh/sshd_config; do [[ -r "$c" ]] && SSHD_CFG="$c"; done if [[ -n "$SSHD_CFG" ]]; then say "" say " ${C_STL}SSH tunnelling policy${C_NC} (${SSHD_CFG})" # effective config if sshd supports -T, else fall back to the file if have_cmd sshd && sshd -T >/dev/null 2>&1; then SSHD_EFF=$(sshd -T 2>/dev/null) GET(){ echo "$SSHD_EFF" | awk -v k="$1" 'tolower($1)==k{print $2; exit}'; } SRC="effective (sshd -T)" else GET(){ grep -Ei "^[[:space:]]*$1[[:space:]]" "$SSHD_CFG" 2>/dev/null | tail -1 | awk '{print tolower($2)}'; } SRC="from file" fi A_TCP=$(GET allowtcpforwarding); A_TCP=${A_TCP:-yes} A_GW=$(GET gatewayports); A_GW=${A_GW:-no} A_TUN=$(GET permittunnel); A_TUN=${A_TUN:-no} A_ROOT=$(GET permitrootlogin); A_ROOT=${A_ROOT:-prohibit-password} A_PW=$(GET passwordauthentication); A_PW=${A_PW:-yes} info "source: $SRC" info "AllowTcpForwarding=$A_TCP GatewayPorts=$A_GW PermitTunnel=$A_TUN" info "PermitRootLogin=$A_ROOT PasswordAuthentication=$A_PW" if [[ "$A_TUN" != "no" ]]; then mark_issue "SSH PermitTunnel=$A_TUN - sshd can build a real layer-3 VPN (tun device). Rarely intended on a server." ACTIVE_EV+=("sshd PermitTunnel enabled") fi if [[ "$A_TCP" == "yes" ]]; then mark_warn "SSH AllowTcpForwarding=yes - any user with SSH access can create a SOCKS proxy (ssh -D) or forward ports. Default, but it IS a tunnel path." fi if [[ "$A_GW" != "no" ]]; then mark_warn "SSH GatewayPorts=$A_GW - forwarded ports can be exposed to other hosts, not just localhost" fi if [[ "$A_PW" == "yes" && "$A_ROOT" == "yes" ]]; then mark_issue "SSH allows root login WITH a password - brute-forceable path to full control" fi else info "sshd_config not readable (run as root for SSH policy detail)" fi # live SSH tunnels: -D (SOCKS), -L / -R (port forwards), autossh, sshuttle SSH_TUN=$(ps -eo pid=,user=,args= 2>/dev/null \ | grep -E '(^|[[:space:]])(ssh|autossh|sshuttle)([[:space:]]|$)' \ | grep -E '[[:space:]]-[DLRW][[:space:]]?[0-9]|sshuttle' \ | grep -v grep | head -10 || true) if [[ -n "$SSH_TUN" ]]; then say " ${C_STL}Live SSH tunnels / SOCKS proxies:${C_NC}" detail "$SSH_TUN" 6 mark_issue "Active SSH tunnel(s) or SOCKS proxy running via ssh -D/-L/-R" ACTIVE_EV+=("live ssh tunnels") fi # sshd listening on non-standard ports (common for hidden tunnel endpoints) if have_cmd ss; then SSH_PORTS=$(ss -tlpn 2>/dev/null | grep -i sshd | grep -oE ':[0-9]+' | tr -d ':' | sort -u | tr '\n' ' ') if [[ -n "$SSH_PORTS" ]]; then info "sshd listening on port(s): $SSH_PORTS" echo "$SSH_PORTS" | grep -qv '^22 *$' && [[ "$SSH_PORTS" != "22 " ]] && \ mark_warn "sshd is listening on a non-standard port ($SSH_PORTS) - normal hardening, but worth confirming it is intentional" fi fi # ---- dual-use networking tools (not VPNs, but useful to know they're here) ---- DUAL_RX='(netcat-openbsd|^nc$|ncat|socat|proxychains|proxychains4|tsocks|redsocks|torsocks|tor)' DUAL_FOUND="" for b in nc ncat socat proxychains proxychains4 tsocks torsocks tor; do P=$(command -v "$b" 2>/dev/null) && DUAL_FOUND+="$b ($P) " done if [[ -n "$DUAL_FOUND" ]]; then say "" say " Dual-use networking tools present (normal on many servers, but they can" say " be used to build a tunnel manually): $DUAL_FOUND" fi # ---- verdict for this section ---- say "" if [[ ${#ACTIVE_EV[@]} -gt 0 ]]; then IDENT=$(printf '%s\n' "$TUN_IFACES" "$ACT_UNITS" "$PROC_HITS" "$BIN_HITS" "$CFG_FOUND" "$PKG_HITS" \ | grep -Eio "$TOOL_RX" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ' ') mark_issue "ACTIVE VPN/proxy/tunnel detected. Identified: ${IDENT:-see evidence above}" elif [[ ${#INSTALLED_EV[@]} -gt 0 ]]; then IDENT=$(printf '%s\n' "$INACT_UNITS" "$BIN_HITS" "$CFG_FOUND" "$PKG_HITS" \ | grep -Eio "$TOOL_RX" | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ' ') mark_warn "VPN/proxy software is INSTALLED but not currently running. Identified: ${IDENT:-see evidence above}" elif [[ ${#HIST_EV[@]} -gt 0 ]]; then mark_warn "No VPN/proxy running or installed now, but HISTORICAL evidence exists (it was installed or run at some point)" else mark_ok "No VPN/tunnel/proxy evidence found (active, installed, or historical)" fi fi # --------------------------------------------------------------------------- # 9b. Proxy configuration detection (env, apt, yum, docker, git, systemd) # --------------------------------------------------------------------------- if [[ "${RUN_PROXY:-1}" -eq 1 ]]; then section "Proxy configuration (terminal / system level)" PROXY_FOUND=0 ENVP=$(env 2>/dev/null | grep -Ei '^(http_proxy|https_proxy|ftp_proxy|all_proxy|no_proxy)=' || true) if [[ -n "$ENVP" ]]; then say " Environment variables in THIS shell:"; say "$ENVP"; PROXY_FOUND=1 fi for f in /etc/environment /etc/profile /root/.bashrc /root/.profile /etc/profile.d/*.sh; do [[ -r "$f" && -f "$f" ]] || continue L=$(grep -Ei '(http_proxy|https_proxy|all_proxy)=' "$f" 2>/dev/null | grep -v '^\s*#' | head -3) [[ -n "$L" ]] && { say " $f:"; say "$L"; PROXY_FOUND=1; } done for f in /etc/apt/apt.conf /etc/apt/apt.conf.d/*proxy* /etc/apt/apt.conf.d/*; do [[ -r "$f" && -f "$f" ]] || continue L=$(grep -Ei 'Acquire::(http|https)::Proxy' "$f" 2>/dev/null | head -3) [[ -n "$L" ]] && { say " apt proxy in $f:"; say "$L"; PROXY_FOUND=1; } done for f in /etc/yum.conf /etc/dnf/dnf.conf; do [[ -r "$f" ]] || continue L=$(grep -Ei '^proxy' "$f" 2>/dev/null | head -3) [[ -n "$L" ]] && { say " $f:"; say "$L"; PROXY_FOUND=1; } done [[ -r /etc/systemd/system/docker.service.d/http-proxy.conf ]] && { say " docker proxy drop-in present:"; say "$(cat /etc/systemd/system/docker.service.d/http-proxy.conf)"; PROXY_FOUND=1; } if have_cmd git; then GP=$(git config --global --get-regexp '^(http|https)\.proxy' 2>/dev/null || true) [[ -n "$GP" ]] && { say " git global proxy:"; say "$GP"; PROXY_FOUND=1; } fi # transparent proxy hint: redirect rules in nat table if have_cmd iptables; then RED=$(iptables -t nat -S 2>/dev/null | grep -Ei 'REDIRECT|DNAT' | head -5 || true) [[ -n "$RED" ]] && { say " NAT redirect/DNAT rules (possible transparent proxy):"; say "$RED"; PROXY_FOUND=1; } fi if [[ "$PROXY_FOUND" -eq 1 ]]; then mark_warn "Proxy configuration found - outbound traffic may be routed through a proxy (see entries above)" else mark_ok "No proxy configuration found (env, apt/yum, docker, git, NAT redirects all clean)" fi fi # --------------------------------------------------------------------------- # 9c. Git / repository connectivity diagnostics (read-only) # Diagnoses the classic "git clone hangs / fails" case: TLS backend, # proxy interference, HTTP/2 issues, and reachability. # --------------------------------------------------------------------------- if [[ "${RUN_GIT:-1}" -eq 1 ]]; then section "Git / repository connectivity" TEST_REPO="${GIT_REPO:-https://github.com}" if have_cmd git; then say " git: $(git --version 2>/dev/null)" GIT_HTTP_BACKEND=$(git config --get http.sslBackend 2>/dev/null) GIT_HTTP_VER=$(git config --get http.version 2>/dev/null) [[ -n "$GIT_HTTP_BACKEND" ]] && say " git http.sslBackend = $GIT_HTTP_BACKEND" [[ -n "$GIT_HTTP_VER" ]] && say " git http.version = $GIT_HTTP_VER" # which TLS library git is linked against GITREMOTE=$(command -v git-remote-https 2>/dev/null) if [[ -n "$GITREMOTE" ]] && have_cmd ldd; then TLSLIB=$(ldd "$GITREMOTE" 2>/dev/null | grep -Ei 'ssl|gnutls|curl' | awk '{print $1}' | tr '\n' ' ') say " git-remote-https links against: ${TLSLIB:-unknown}" fi else mark_warn "git not installed - skipping git-specific checks" fi have_cmd curl && say " curl: $(curl -V 2>/dev/null | head -1)" # reachability of the repo host over HTTPS if have_cmd curl; then GH_CODE=$(run_q "curl -s -o /dev/null -w '%{http_code}' --max-time 10 ${TEST_REPO}" 12) say " ${TEST_REPO} -> HTTP ${GH_CODE:-timeout}" if [[ "$GH_CODE" =~ ^[23] ]]; then mark_ok "Repository host reachable over HTTPS ($GH_CODE)" else mark_issue "Repository host NOT reachable over HTTPS (got '${GH_CODE:-timeout}') - check DNS, egress firewall, or proxy" fi fi # actual git protocol test (read-only: ls-remote does not clone or write) if have_cmd git; then if [[ "$TEST_REPO" =~ ^https?:// ]]; then LS_TARGET="$TEST_REPO" [[ "$LS_TARGET" == "https://github.com" ]] && LS_TARGET="https://github.com/git/git.git" if GIT_TERMINAL_PROMPT=0 run_q "git ls-remote --heads $LS_TARGET" 25 >/dev/null; then mark_ok "git ls-remote succeeded against $LS_TARGET - git protocol works end to end" else mark_issue "git ls-remote FAILED against $LS_TARGET" say " Common causes, in order: (1) proxy/firewall blocking, (2) http.version" say " forced to HTTP/2 on an old curl, (3) mismatched http.sslBackend." say " Inspect with: git config --show-origin --list" say " (This script does not change git config - unset those settings" say " yourself only if you confirm they are the cause.)" fi fi fi fi # --------------------------------------------------------------------------- # 9d. Control panel health (DirectAdmin / cPanel / Plesk / aaPanel) - read only # --------------------------------------------------------------------------- if [[ "${RUN_PANEL:-1}" -eq 1 ]]; then section "Control panel status & recent errors" PANEL_SEEN=0 check_panel_log(){ # $1=label $2=logfile [[ -r "$2" ]] || return 0 PANEL_SEEN=1 say " --- $1: last errors from $2" ERRS=$(grep -Ei 'error|fail|denied|refused|timeout|critical' "$2" 2>/dev/null | tail -12) if [[ -n "$ERRS" ]]; then say "$ERRS" mark_warn "$1 log contains recent errors - review $2" else say " no error lines in the recent tail" mark_ok "$1 log shows no recent errors" fi } # DirectAdmin if [[ -d /usr/local/directadmin ]]; then say " DirectAdmin detected" have_cmd systemctl && say " service: $(systemctl is-active directadmin 2>/dev/null)" check_panel_log "DirectAdmin" /var/log/directadmin/error.log check_panel_log "DirectAdmin system" /var/log/directadmin/system.log fi # cPanel if [[ -d /usr/local/cpanel ]]; then say " cPanel detected" check_panel_log "cPanel" /usr/local/cpanel/logs/error_log fi # Plesk if [[ -d /opt/psa || -d /usr/local/psa ]]; then say " Plesk detected" check_panel_log "Plesk" /var/log/plesk/panel.log fi # aaPanel / BT if [[ -d /www/server/panel ]]; then say " aaPanel detected" check_panel_log "aaPanel" /www/server/panel/logs/error.log fi # web servers commonly paired with panels for L in /var/log/nginx/error.log /var/log/httpd/error_log /var/log/apache2/error.log /usr/local/lsws/logs/error.log; do [[ -r "$L" ]] && { PANEL_SEEN=1; check_panel_log "$(basename "$(dirname "$L")")" "$L"; } done [[ "$PANEL_SEEN" -eq 0 ]] && say " No control panel or web-server logs found/readable on this host" fi # --------------------------------------------------------------------------- # 10. Package repository reachability (distro-aware) # --------------------------------------------------------------------------- if [[ "$RUN_REPO_TEST" -eq 1 ]]; then section "Package repository reachability" case "$PKG_MGR" in apt) if run_q "apt-get update -qq" 40; then mark_ok "apt-get update succeeded (repos reachable)" else mark_issue "apt-get update failed - check mirror reachability / DNS / proxy (see $LOGFILE)" fi ;; dnf|yum) if run_q "$PKG_MGR makecache -q" 40; then mark_ok "$PKG_MGR makecache succeeded (repos reachable)" else mark_issue "$PKG_MGR makecache failed - check mirror reachability / DNS / proxy (see $LOGFILE)" fi ;; zypper) if run_q "zypper --non-interactive refresh" 40; then mark_ok "zypper refresh succeeded" else mark_issue "zypper refresh failed" fi ;; *) mark_warn "Skipping repo test - unrecognized package manager" ;; esac fi # --------------------------------------------------------------------------- # 11. Public IP # --------------------------------------------------------------------------- section "Public IP / geo / NAT" if [[ "${ALLOW_EXTERNAL:-1}" -eq 0 ]]; then say " Skipped - you declined external lookups, so no data about this server" say " was sent to any third party. Internal checks above are unaffected." elif have_cmd curl; then PUB4=$(run_q "curl -4 -s --max-time 5 https://icanhazip.com" 7 | tr -d '[:space:]') PUB6=$(run_q "curl -6 -s --max-time 5 https://icanhazip.com" 7 | tr -d '[:space:]') if [[ "$PUB4" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then say " Public (external) IPv4: $PUB4" mark_ok "Public IPv4 reachable ($PUB4)" # internal vs external: does the outward-facing IP match the local primary IP? if [[ -n "${PRIMARY_IP:-}" ]]; then if [[ "$PUB4" == "$PRIMARY_IP" ]]; then mark_ok "External IP matches the local primary IP - no NAT/proxy in front (direct public binding)" else mark_warn "External IP ($PUB4) differs from local primary IP ($PRIMARY_IP) - traffic is NATed or passing through a proxy/edge" fi fi # geo / location check via a public IP-geo API (read-only lookup) GEO_JSON=$(run_q "curl -s --max-time 6 https://ipinfo.io/${PUB4}/json" 8) GEO_CC=$(echo "$GEO_JSON" | grep -oE '"country":[[:space:]]*"[A-Z]{2}"' | grep -oE '[A-Z]{2}' | head -1) GEO_CITY=$(echo "$GEO_JSON" | grep -oE '"city":[[:space:]]*"[^"]*"' | sed 's/.*"city":[[:space:]]*"//;s/"//') GEO_ORG=$(echo "$GEO_JSON" | grep -oE '"org":[[:space:]]*"[^"]*"' | sed 's/.*"org":[[:space:]]*"//;s/"//') if [[ -n "$GEO_CC" ]]; then say " Geo: ${GEO_CC} ${GEO_CITY:+/ $GEO_CITY} ${GEO_ORG:+($GEO_ORG)}" if [[ -n "$EXPECTED_GEO" ]]; then if [[ "${GEO_CC^^}" == "${EXPECTED_GEO^^}" ]]; then mark_ok "Public IP geolocates to ${GEO_CC}, matching the expected ${EXPECTED_GEO^^}" else mark_issue "Public IP geolocates to ${GEO_CC} but you expected ${EXPECTED_GEO^^} - IP is NOT in the required location" fi fi else mark_warn "Could not determine geolocation (ipinfo.io unreachable or rate-limited)" fi else mark_warn "Could not confirm public IPv4 (no egress, or icanhazip.com unreachable)" fi [[ "$PUB6" == *:* ]] && say " Public IPv6: $PUB6" fi # --------------------------------------------------------------------------- # 12. Domain-specific checks (only if a domain was supplied) # --------------------------------------------------------------------------- if [[ -n "$DOMAIN" ]]; then section "DNS for $DOMAIN" if have_cmd dig; then say " A: $(dig +short A "$DOMAIN" | tr '\n' ' ')" say " AAAA: $(dig +short AAAA "$DOMAIN" | tr '\n' ' ')" say " NS: $(dig +short NS "$DOMAIN" | tr '\n' ' ')" elif have_cmd getent; then say " $(getent hosts "$DOMAIN")" fi if getent hosts "$DOMAIN" >/dev/null 2>&1; then mark_ok "$DOMAIN resolves" else mark_issue "$DOMAIN does not resolve" fi if [[ "$RUN_TLS" -eq 1 ]] && have_cmd openssl; then section "TLS handshake for $DOMAIN:443" TLS_OUT=$(run_q "echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>&1" 8) if echo "$TLS_OUT" | grep -q "Verify return code: 0"; then mark_ok "TLS handshake OK, certificate verifies" elif echo "$TLS_OUT" | grep -qi "Verify return code"; then mark_warn "TLS handshake completed but certificate did not verify cleanly" else mark_issue "TLS handshake to $DOMAIN:443 failed" fi say "$(echo "$TLS_OUT" | grep -E 'subject=|issuer=|Verify return code|Protocol' )" fi if have_cmd curl; then section "Web reachability / crawlability for $DOMAIN" HTTP_CODE=$(run_q "curl -s -o /dev/null -w '%{http_code}' -L --max-time 8 https://${DOMAIN}/" 10) say " https://$DOMAIN -> HTTP $HTTP_CODE" [[ "$HTTP_CODE" =~ ^2|^3 ]] && mark_ok "Site responds ($HTTP_CODE)" || mark_issue "Site returned HTTP $HTTP_CODE" # If the site looks unavailable, trace WHERE it breaks: DNS -> TCP -> HTTP -> path if [[ ! "$HTTP_CODE" =~ ^2|^3 ]]; then section "Site unreachable - tracing the break point for $DOMAIN" # 1. does it resolve? SITE_IP=$(getent hosts "$DOMAIN" 2>>"$LOGFILE" | awk '{print $1; exit}') if [[ -z "$SITE_IP" ]]; then mark_issue "Break at DNS: $DOMAIN does not resolve to any IP - fix the A/AAAA record" else say " Resolves to: $SITE_IP" # 2. is 443 open at the TCP layer? if run_q "timeout 5 bash -c ' HTTP $GBOT_CODE" [[ "$GBOT_CODE" == "$HTTP_CODE" ]] && mark_ok "No difference in response for Googlebot UA" || mark_warn "Response differs for Googlebot UA ($GBOT_CODE vs $HTTP_CODE) - check for cloaking/UA-blocking" ROBOTS=$(run_q "curl -s --max-time 6 https://${DOMAIN}/robots.txt" 8) if [[ -n "$ROBOTS" ]]; then say " robots.txt found ($(echo "$ROBOTS" | wc -l) lines)" echo "$ROBOTS" | grep -qi 'Disallow: /$' && mark_issue "robots.txt disallows the entire site ('Disallow: /')" else mark_warn "No robots.txt found" fi if run_q "curl -s -o /dev/null -w '%{http_code}' --max-time 6 https://${DOMAIN}/sitemap.xml" 8 | grep -q '^200$'; then mark_ok "sitemap.xml present" elif run_q "curl -s -o /dev/null -w '%{http_code}' --max-time 6 https://${DOMAIN}/sitemap_index.xml" 8 | grep -q '^200$'; then mark_ok "sitemap_index.xml present" else mark_warn "No sitemap.xml / sitemap_index.xml found at the default paths" fi BODY=$(run_q "curl -s -L --max-time 8 https://${DOMAIN}/" 10) echo "$BODY" | grep -qi 'noindex' && mark_warn "Page contains a 'noindex' directive" echo "$BODY" | grep -q 'application/ld+json' && mark_ok "Structured data (JSON-LD) present" || mark_warn "No JSON-LD structured data detected on homepage" say " Manual follow-up: https://search.google.com/test/rich-results?url=https://${DOMAIN}" fi fi # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- section "SUMMARY" if [[ ${#ISSUE_ITEMS[@]} -eq 0 && ${#WARN_ITEMS[@]} -eq 0 ]]; then say " ${C_GRN}${C_BLD}VERDICT: ALL CLEAR${C_NC} - no issues or warnings found." elif [[ ${#ISSUE_ITEMS[@]} -eq 0 ]]; then say " ${C_YEL}${C_BLD}VERDICT: OK WITH WARNINGS${C_NC} - no hard issues, but review the warnings below." else say " ${C_RED}${C_BLD}VERDICT: ISSUES FOUND${C_NC} - see the issue list below." fi say " ${C_GRN}OK:${C_NC} ${#OK_ITEMS[@]} ${C_YEL}WARN:${C_NC} ${#WARN_ITEMS[@]} ${C_RED}ISSUE:${C_NC} ${#ISSUE_ITEMS[@]}" if [[ ${#ISSUE_ITEMS[@]} -gt 0 ]]; then say "\n ${C_RED}Issues to look at:${C_NC}" for i in "${ISSUE_ITEMS[@]}"; do say " - $i"; done fi if [[ ${#WARN_ITEMS[@]} -gt 0 ]]; then say "\n ${C_YEL}Warnings (context-dependent, may be fine):${C_NC}" for w in "${WARN_ITEMS[@]}"; do say " - $w"; done fi say "\nFull detail log: $LOGFILE" say "Finished: $(date)" say "\nThis script made no configuration changes. The only state touched was" say "the local package-index cache (if you kept the repo-reachability test on)" say "and this temporary log file, which you can delete at any time."