#!/usr/bin/env bash # check-vps.sh — verify the joshbairstow.com VPS is reachable and correctly # configured, before and after the jb-infra bootstrap. READ-ONLY: it changes # nothing on the box or in DNS. # # What it checks: # - ICMP reachability (IPv4, and IPv6 if you pass --ip6) # - TCP ports 22/80/443/222 (firewall + whether a service is listening yet) # - SSH login with your key, then reports the box's OS / kernel / RAM / swap / # Docker / compose / the `edge` network / its own public IPs # - DNS for the apex, the wildcard, and each subdomain vs the expected IP(s) # # Usage: # ./check-vps.sh # ./check-vps.sh --ip6 2001:19f0:... --key ~/.ssh/id_ed25519 # VPS_IP=1.2.3.4 DOMAIN=example.com ./check-vps.sh # # Exit status: 0 if the CRITICAL checks pass (SSH login + port 22), else 1. # Warnings (!) — closed app ports, unset DNS — are EXPECTED before you update # DNS and bring up the edge/Forgejo stack, so they don't fail the run. set -u # ---- defaults (override via env or flags) ---------------------------------- VPS_IP="${VPS_IP:-45.32.242.27}" VPS_IP6="${VPS_IP6:-}" DOMAIN="${DOMAIN:-joshbairstow.com}" SSH_USER="${SSH_USER:-root}" SSH_PORT="${SSH_PORT:-22}" SSH_KEY="${SSH_KEY:-}" SUBDOMAINS=(www blog art code git) TCP_PORTS=(22 80 443 222) while [ $# -gt 0 ]; do case "$1" in --ip) VPS_IP="$2"; shift 2;; --ip6) VPS_IP6="$2"; shift 2;; --domain) DOMAIN="$2"; shift 2;; --user) SSH_USER="$2"; shift 2;; --ssh-port) SSH_PORT="$2"; shift 2;; --key) SSH_KEY="$2"; shift 2;; -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; *) echo "unknown arg: $1" >&2; exit 2;; esac done # ---- output helpers -------------------------------------------------------- if [ -t 1 ]; then R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; B=$'\e[1;34m'; D=$'\e[2m'; Z=$'\e[0m' else R=; G=; Y=; B=; D=; Z=; fi pass=0; warn=0; fail=0 ok() { printf ' %s✓%s %s\n' "$G" "$Z" "$*"; pass=$((pass+1)); } nok() { printf ' %s✗%s %s\n' "$R" "$Z" "$*"; fail=$((fail+1)); } note() { printf ' %s!%s %s\n' "$Y" "$Z" "$*"; warn=$((warn+1)); } info() { printf ' %s·%s %s\n' "$D" "$Z" "$*"; } hdr() { printf '\n%s== %s ==%s\n' "$B" "$*" "$Z"; } have() { command -v "$1" >/dev/null 2>&1; } printf '%sVPS pre-flight%s ip=%s ip6=%s domain=%s ssh=%s@%s:%s\n' \ "$B" "$Z" "$VPS_IP" "${VPS_IP6:-none}" "$DOMAIN" "$SSH_USER" "$VPS_IP" "$SSH_PORT" # ---- DNS resolvers (dig → host → getent) ----------------------------------- resolve_a() { if have dig; then dig +short A "$1" | grep -E '^[0-9.]+$' elif have host; then host -t A "$1" 2>/dev/null | sed -n 's/.*has address //p' else getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u; fi } resolve_aaaa() { if have dig; then dig +short AAAA "$1" | grep -E ':' elif have host; then host -t AAAA "$1" 2>/dev/null | sed -n 's/.*IPv6 address //p' else getent ahostsv6 "$1" 2>/dev/null | awk '{print $1}' | sort -u; fi } # ---- TCP port probe (bash /dev/tcp — variant-independent) ------------------- check_port() { # host port -> "open" | "closed" if timeout 4 bash -c ">/dev/tcp/$1/$2" 2>/dev/null; then echo open; else echo closed; fi } # ---- 1. ICMP --------------------------------------------------------------- hdr "ICMP reachability" if ping -c1 -W3 "$VPS_IP" >/dev/null 2>&1; then ok "ping $VPS_IP (IPv4)" else note "ping $VPS_IP (IPv4) no reply — fine if ICMP isn't allowed in the firewall" fi if [ -n "$VPS_IP6" ]; then if ping -6 -c1 -W3 "$VPS_IP6" >/dev/null 2>&1; then ok "ping $VPS_IP6 (IPv6)" else note "ping $VPS_IP6 (IPv6) no reply — your local link may lack IPv6"; fi else info "no --ip6 given; skipping IPv6 ping (SSH will report the box's v6 address)" fi # ---- 2. TCP ports ---------------------------------------------------------- hdr "TCP ports (firewall + listeners)" for p in "${TCP_PORTS[@]}"; do st=$(check_port "$VPS_IP" "$p") case "$p:$st" in 22:open) ok "22 SSH — open" ;; 22:*) nok "22 SSH — $st (you can't manage the box; check firewall 22 + sshd)" ;; *:open) ok "$p open (service listening)" ;; *:*) note "$p $st — expected until the edge/Forgejo stack is up" ;; esac done # ---- 3. SSH login + remote facts ------------------------------------------- hdr "SSH login + remote facts" ssh_opts=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -p "$SSH_PORT") [ -n "$SSH_KEY" ] && ssh_opts+=(-i "$SSH_KEY") err=$(mktemp) remote=$(ssh "${ssh_opts[@]}" "$SSH_USER@$VPS_IP" ' . /etc/os-release 2>/dev/null echo "OS=${PRETTY_NAME:-unknown}" echo "KERNEL=$(uname -r)" echo "ARCH=$(uname -m)" mem_kb=$(grep MemTotal /proc/meminfo | tr -dc 0-9); echo "MEM_MB=$((mem_kb/1024))" swap_kb=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); echo "SWAP_MB=$((swap_kb/1024))" echo "PUBIP4=$(ip -4 -o addr show scope global 2>/dev/null | sed -n "s@.*inet \([0-9.]*\)/.*@\1@p" | head -1)" echo "PUBIP6=$(ip -6 -o addr show scope global 2>/dev/null | sed -n "s@.*inet6 \([0-9a-f:]*\)/.*@\1@p" | head -1)" if command -v docker >/dev/null 2>&1; then echo "DOCKER=$(docker --version 2>/dev/null)"; else echo "DOCKER=none"; fi echo "COMPOSE=$(docker compose version --short 2>/dev/null || echo none)" if docker network ls --format "{{.Name}}" 2>/dev/null | grep -qx edge; then echo "EDGE_NET=present"; else echo "EDGE_NET=absent"; fi ' 2>"$err") rc=$? if [ $rc -eq 0 ]; then ok "SSH login as $SSH_USER@$VPS_IP:$SSH_PORT" # surface every fact, then sanity-check the important ones while IFS='=' read -r k v; do [ -n "$k" ] && info "$k = $v"; done <<<"$remote" get() { printf '%s\n' "$remote" | sed -n "s/^$1=//p"; } case "$(get OS)" in *[Dd]ebian*13*|*trixie*) ok "OS is Debian 13 (trixie)";; *[Dd]ebian*) note "Debian, but not 13: $(get OS)";; *) note "unexpected OS: $(get OS)";; esac [ "$(get MEM_MB)" -ge 1900 ] 2>/dev/null && ok "RAM ~2 GB ($(get MEM_MB) MB)" \ || note "RAM is $(get MEM_MB) MB — expected ~2 GB" [ "$(get PUBIP4)" = "$VPS_IP" ] && ok "box's public IPv4 matches ($VPS_IP)" \ || note "box reports IPv4 $(get PUBIP4), expected $VPS_IP" if [ -n "$(get PUBIP6)" ]; then if [ -n "$VPS_IP6" ] && [ "$(get PUBIP6)" = "$VPS_IP6" ]; then ok "box's public IPv6 matches" else info "box's public IPv6 is $(get PUBIP6) — set this as your AAAA records"; fi fi [ "$(get DOCKER)" = none ] && note "Docker not installed yet (runbook step 2)" || ok "Docker present: $(get DOCKER)" [ "$(get EDGE_NET)" = present ] && ok "'edge' network exists" || info "'edge' network not created yet (runbook step 3)" else nok "SSH login failed (rc=$rc): $(tr -d '\n' <"$err")" info "checklist: key uploaded to Vultr? user is '$SSH_USER'? firewall allows 22 from your IP? try --key " fi rm -f "$err" # ---- 4. DNS ---------------------------------------------------------------- hdr "DNS records (expecting → $VPS_IP${VPS_IP6:+ / $VPS_IP6})" check_name() { local fqdn=$1 a aaaa a=$(resolve_a "$fqdn" | paste -sd, -) aaaa=$(resolve_aaaa "$fqdn" | paste -sd, -) if [ -z "$a$aaaa" ]; then note "$fqdn — no record yet"; return; fi if printf '%s' "$a" | grep -qw "$VPS_IP"; then ok "$fqdn A → $a" else note "$fqdn A → ${a:-none} (expected $VPS_IP)"; fi if [ -n "$VPS_IP6" ]; then if printf '%s' "$aaaa" | grep -qiw "$VPS_IP6"; then ok "$fqdn AAAA → $aaaa" else note "$fqdn AAAA → ${aaaa:-none} (expected $VPS_IP6)"; fi elif [ -n "$aaaa" ]; then info "$fqdn AAAA → $aaaa"; fi } check_name "$DOMAIN" check_name "wildcard-probe.$DOMAIN" # resolves only if *.DOMAIN is set for s in "${SUBDOMAINS[@]}"; do check_name "$s.$DOMAIN"; done # ---- summary --------------------------------------------------------------- hdr "Summary" printf ' %s%d passed%s · %s%d warnings%s · %s%d failed%s\n' \ "$G" "$pass" "$Z" "$Y" "$warn" "$Z" "$R" "$fail" "$Z" if [ "$fail" -gt 0 ]; then printf ' %sCritical checks failed%s — see ✗ above.\n' "$R" "$Z"; exit 1 fi printf ' No critical failures. Warnings (!) are expected before DNS update / stack bootstrap.\n'