From 75fabc864e44e647d292a8497242a1286670b717 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 16:13:45 +1000 Subject: [PATCH] docs: record live VPS details + DNS-at-Cloudflare caveat; add pre-flight script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/check-vps.sh: read-only reachability/ports/SSH-facts/DNS checker - README: real IPv4/IPv6 + Debian 13 + existing swap; firewall already on Vultr - DNS is managed at Cloudflare (NS delegated), not VentraIP — records must be DNS-only (grey cloud) so Caddy issues real certs and port 222 works - fix git remote to ssh:// form for the custom 222 port Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 73 ++++++++++++------ scripts/check-vps.sh | 174 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 23 deletions(-) create mode 100755 scripts/check-vps.sh diff --git a/README.md b/README.md index 1eb7c1e..96f24ec 100644 --- a/README.md +++ b/README.md @@ -27,36 +27,62 @@ declares routing labels. --- +## The VPS (provisioned 2026-06-02) + +Vultr shared-CPU **2 GB**, **Debian 13 (trixie)**, kernel 6.12, ~5.4 GB swap +already present (no need to add any). Desktop SSH key installed; Vultr firewall +groups applied on both IPv4 and IPv6. + +| | | +|---|---| +| IPv4 | `45.32.242.27` | +| IPv6 | `2001:19f0:5801:1313:5400:6ff:fe35:c0a7` | +| SSH | `root@45.32.242.27` (port 22) | + +## Pre-flight check + +Before and after each bootstrap step, run the read-only checker from your +desktop to confirm reachability, ports, the box's facts, and DNS: + +```sh +./scripts/check-vps.sh --ip6 2001:19f0:5801:1313:5400:6ff:fe35:c0a7 +``` + +It exits 0 when SSH + port 22 are good; closed app ports and unset DNS show as +warnings (expected until DNS is updated and the stack is up). + ## Bootstrap (one-time, in order) Order matters — TLS issuance needs DNS + open ports first, and the `edge` network must exist before any stack references it. -### 1. DNS -Point at the VPS public IP, and **wait for propagation** before step 4: -- `A joshbairstow.com → ` -- `A *.joshbairstow.com → ` (wildcard covers blog/art/code/git + future) -- `AAAA` equivalents if the VPS has IPv6. +### 1. DNS — managed at **Cloudflare**, not VentraIP +VentraIP is only the registrar; the domain's nameservers are delegated to +Cloudflare (`grace/keenan.ns.cloudflare.com`), so create the records **in the +Cloudflare dashboard**. Point them at the VPS and **wait for propagation**: +- `A joshbairstow.com → 45.32.242.27` +- `A *.joshbairstow.com → 45.32.242.27` (wildcard: covers blog/art/code/git + future) +- `AAAA joshbairstow.com → 2001:19f0:5801:1313:5400:6ff:fe35:c0a7` +- `AAAA *.joshbairstow.com → 2001:19f0:5801:1313:5400:6ff:fe35:c0a7` -A wildcard record means new subdomains resolve with no DNS change; certs are -still issued per-host via HTTP-01, so no DNS-challenge plugin is required. +> **Set these records to DNS-only (grey cloud), not proxied (orange cloud).** +> The current records point at Cloudflare's proxy. With the proxy ON: Cloudflare +> terminates TLS with its own edge cert (so Caddy's automatic HTTPS is bypassed), +> and it only proxies HTTP(S) ports — **git-over-SSH on 222 and the registry +> would break**. DNS-only makes clients hit the VPS directly, so Caddy issues +> real Let's Encrypt certs via HTTP-01 (no DNS-challenge plugin needed) and all +> ports work. If you later want Cloudflare's proxy/CDN, switch Caddy to the +> DNS-01 challenge with the Cloudflare plugin + an API token — a separate change. ### 2. VPS base +Firewall is already handled by the Vultr firewall group (80, 443, 222, 22), so +host `ufw` is optional. Swap already exists. The remaining task is Docker: ```sh -# Install Docker Engine + compose plugin (official convenience script or distro pkg) +# Install Docker Engine + compose plugin curl -fsSL https://get.docker.com | sh - -# Firewall: allow only what we serve. -# 80, 443 -> edge proxy (HTTP/HTTPS, incl. ACME challenges) -# 222 -> Forgejo git-over-SSH -# 22 -> your normal host SSH (keep it!) -ufw allow 80,443/tcp && ufw allow 222/tcp && ufw allow OpenSSH && ufw enable -``` -RAM: prefer the **2 GB** Vultr tier. On 1 GB, add swap first: -```sh -fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile -echo '/swapfile none swap sw 0 0' >> /etc/fstab +docker --version && docker compose version # confirm ``` +(Re-run `./scripts/check-vps.sh` — `DOCKER` should now report a version.) ### 3. Create the shared network + bring up the stack ```sh @@ -90,8 +116,8 @@ forgejo-runner daemon --config /path/to/jb-infra/runner/config.yml ### 6. Wire the jb-website repo In the `jb-website` repo settings on Forgejo (`Settings → Actions`): - **Variables:** `REGISTRY_OWNER` = your lowercase owner. -- **Secrets:** `REGISTRY_USER`, `REGISTRY_TOKEN` (the PAT), `DEPLOY_HOST`, - `DEPLOY_USER`, `DEPLOY_SSH_KEY`. +- **Secrets:** `REGISTRY_USER`, `REGISTRY_TOKEN` (the PAT), `DEPLOY_HOST` + (`45.32.242.27`), `DEPLOY_USER`, `DEPLOY_SSH_KEY`. On the **VPS**, prepare the deploy target: ```sh @@ -105,8 +131,9 @@ of `DEPLOY_SSH_KEY`. ### 7. First deploy ```sh -# in the jb-website repo (rename master→main first; see that repo's README) -git remote add origin git@git.joshbairstow.com:222//jb-website.git +# in the jb-website repo (default branch is already `main`). +# Custom SSH port 222 needs the ssh:// URL form (scp-style host:port doesn't work): +git remote add origin ssh://git@git.joshbairstow.com:222//jb-website.git git push -u origin main ``` The push triggers `.forgejo/workflows/deploy.yml`: the desktop builds + pushes diff --git a/scripts/check-vps.sh b/scripts/check-vps.sh new file mode 100755 index 0000000..f8e6e01 --- /dev/null +++ b/scripts/check-vps.sh @@ -0,0 +1,174 @@ +#!/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'