Initial VPS operations handbook
This commit is contained in:
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read-only health check for mx2.windy.me mailcow.
|
||||
# Usage: ./scripts/check-mx2.sh
|
||||
# Env:
|
||||
# MX2_HOST default mx2.windy.me
|
||||
# MX2_SSH_USER default windy
|
||||
# MX2_ALLOW_IPV6 set to 1 to allow IPv6 (default forces IPv4; WSL often lacks v6 route)
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${MX2_HOST:-mx2.windy.me}"
|
||||
SSH_USER="${MX2_SSH_USER:-windy}"
|
||||
|
||||
# Default -4: host has AAAA but many clients (WSL) have no IPv6 route → "Network is unreachable"
|
||||
SSH_AF_OPTS=(-4)
|
||||
CURL_AF_OPTS=(-4)
|
||||
OPENSSL_AF_OPTS=(-4)
|
||||
if [[ "${MX2_ALLOW_IPV6:-0}" == "1" ]]; then
|
||||
SSH_AF_OPTS=()
|
||||
CURL_AF_OPTS=()
|
||||
OPENSSL_AF_OPTS=()
|
||||
fi
|
||||
|
||||
SSH=(ssh "${SSH_AF_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=10 "${SSH_USER}@${HOST}")
|
||||
# Prefer public resolvers: local stub (127.0.0.53) is flaky from some WSL setups
|
||||
DIG_OPTS=(+time=3 +tries=2 +short)
|
||||
DIG_SERVERS=(1.1.1.1 8.8.8.8)
|
||||
FAILURES=0
|
||||
|
||||
section() { printf '\n===== %s =====\n' "$1"; }
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
FAILURES=$((FAILURES + 1))
|
||||
}
|
||||
|
||||
# Strip dig commentary (timeouts are printed on stdout as ";; ...")
|
||||
dig_answers_only() {
|
||||
grep -v '^;;' | grep -v '^$' || true
|
||||
}
|
||||
|
||||
# dig_short <name> [type] — tries each public resolver; skips timeout noise
|
||||
dig_short() {
|
||||
local name="$1"
|
||||
local typ="${2:-A}"
|
||||
local server out
|
||||
for server in "${DIG_SERVERS[@]}"; do
|
||||
out="$(dig @"${server}" "${DIG_OPTS[@]}" "$name" "$typ" 2>/dev/null | dig_answers_only)" || out=""
|
||||
if [[ -n "$out" ]]; then
|
||||
printf '%s\n' "$out"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# dig_ptr <ip>
|
||||
dig_ptr() {
|
||||
local ip="$1"
|
||||
local server out
|
||||
for server in "${DIG_SERVERS[@]}"; do
|
||||
out="$(dig @"${server}" "${DIG_OPTS[@]}" -x "$ip" 2>/dev/null | dig_answers_only)" || out=""
|
||||
if [[ -n "$out" ]]; then
|
||||
printf '%s\n' "$out"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_ptrs() {
|
||||
local ip ptr
|
||||
while read -r ip; do
|
||||
[[ -z "$ip" ]] && continue
|
||||
ptr="$(dig_ptr "$ip" || true)"
|
||||
printf 'PTR %s -> %s\n' "$ip" "${ptr:-<none>}"
|
||||
grep -Fiq "${HOST}" <<<"$ptr" || fail "PTR for ${ip} does not mention ${HOST}"
|
||||
done
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "missing required command: $1" >&2
|
||||
exit 127
|
||||
}
|
||||
}
|
||||
|
||||
need_cmd ssh
|
||||
need_cmd curl
|
||||
need_cmd openssl
|
||||
need_cmd dig
|
||||
need_cmd timeout
|
||||
|
||||
section "SSH + compose ps"
|
||||
PS_OUT="$("${SSH[@]}" 'cd /opt/mail && docker compose ps -a')" || {
|
||||
fail "ssh or docker compose ps"
|
||||
echo "Cannot continue without SSH/compose." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$PS_OUT"
|
||||
if grep -qiE 'Exited|Restarting|[[:space:]]Dead[[:space:]]' <<<"$PS_OUT"; then
|
||||
fail "compose has Exited/Restarting/Dead containers"
|
||||
fi
|
||||
for svc in nginx-mailcow postfix-mailcow dovecot-mailcow mysql-mailcow; do
|
||||
grep -Fq "$svc" <<<"$PS_OUT" || fail "missing service ${svc}"
|
||||
done
|
||||
|
||||
section "Watchdog (tail)"
|
||||
if ! "${SSH[@]}" 'cd /opt/mail && docker compose logs --tail=40 watchdog-mailcow'; then
|
||||
fail "watchdog logs"
|
||||
fi
|
||||
|
||||
section "Mail queue"
|
||||
QUEUE_OUT="$("${SSH[@]}" 'cd /opt/mail && docker compose exec -T postfix-mailcow postqueue -p' 2>&1)" || {
|
||||
fail "postqueue"
|
||||
QUEUE_OUT=""
|
||||
}
|
||||
printf '%s\n' "$QUEUE_OUT"
|
||||
if [[ -n "$QUEUE_OUT" ]] && ! grep -Fiq 'Mail queue is empty' <<<"$QUEUE_OUT"; then
|
||||
fail "mail queue not empty"
|
||||
fi
|
||||
|
||||
section "Listeners"
|
||||
LISTEN_OUT="$("${SSH[@]}" 'ss -tlnp 2>/dev/null | grep -E ":(25|465|587|110|143|993|995|80|443|4190)[[:space:]]" || true')"
|
||||
printf '%s\n' "$LISTEN_OUT"
|
||||
for p in 25 465 587 110 143 993 995 80 443 4190; do
|
||||
if ! grep -qE ":${p}[[:space:]]" <<<"$LISTEN_OUT"; then
|
||||
fail "port ${p} not listening on host"
|
||||
fi
|
||||
done
|
||||
|
||||
section "HTTP / HTTPS"
|
||||
HTTP_OUT="$(curl "${CURL_AF_OPTS[@]}" -sS -I --max-time 10 "http://${HOST}/" 2>&1 | head -8)" || true
|
||||
printf '%s\n' "$HTTP_OUT"
|
||||
echo "---"
|
||||
# No -k: surface TLS trust problems
|
||||
HTTPS_OUT="$(curl "${CURL_AF_OPTS[@]}" -sS -I --max-time 10 "https://${HOST}/" 2>&1 | head -15)" || true
|
||||
printf '%s\n' "$HTTPS_OUT"
|
||||
grep -qE '^HTTP/[0-9.]+ 301' <<<"$HTTP_OUT" || fail "HTTP did not redirect (expect 301)"
|
||||
grep -qE '^HTTP/[0-9.]+ 200' <<<"$HTTPS_OUT" || fail "HTTPS did not return 200"
|
||||
|
||||
section "TLS cert (443)"
|
||||
CERT_OUT="$(
|
||||
timeout 15 openssl s_client "${OPENSSL_AF_OPTS[@]}" -connect "${HOST}:443" -servername "${HOST}" </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -subject -issuer -dates 2>/dev/null
|
||||
)" || true
|
||||
printf '%s\n' "$CERT_OUT"
|
||||
if ! grep -Fq "CN=${HOST}" <<<"$CERT_OUT" && ! grep -Fq "CN = ${HOST}" <<<"$CERT_OUT"; then
|
||||
fail "TLS subject missing ${HOST}"
|
||||
fi
|
||||
grep -Fiq "Let's Encrypt" <<<"$CERT_OUT" || fail "TLS issuer not Let's Encrypt"
|
||||
|
||||
section "SMTP banner"
|
||||
# Connect via A record so /dev/tcp does not pick unreachable AAAA first
|
||||
SMTP_IP="$(dig_short "${HOST}" A 2>/dev/null | head -n1 || true)"
|
||||
if [[ -z "$SMTP_IP" ]]; then
|
||||
fail "cannot resolve A for SMTP check"
|
||||
SMTP_OUT=""
|
||||
else
|
||||
SMTP_OUT="$(
|
||||
timeout 8 bash -c "exec 3<>/dev/tcp/${SMTP_IP}/25; printf 'EHLO test.local\r\nQUIT\r\n' >&3; cat <&3" 2>/dev/null \
|
||||
| head -20
|
||||
)" || true
|
||||
fi
|
||||
printf 'connect %s:25\n' "${SMTP_IP:-?}"
|
||||
printf '%s\n' "$SMTP_OUT"
|
||||
grep -qiE 'Postcow|ESMTP' <<<"$SMTP_OUT" || fail "SMTP banner unexpected"
|
||||
|
||||
section "DNS"
|
||||
A_REC="$(dig_short "${HOST}" A || true)"
|
||||
AAAA_REC="$(dig_short "${HOST}" AAAA || true)"
|
||||
printf 'A: %s\n' "${A_REC:-<none>}"
|
||||
printf 'AAAA: %s\n' "${AAAA_REC:-<none>}"
|
||||
[[ -n "$A_REC" ]] || fail "missing A record"
|
||||
[[ -n "$AAAA_REC" ]] || fail "missing AAAA record"
|
||||
|
||||
check_ptrs <<<"$A_REC"
|
||||
check_ptrs <<<"$AAAA_REC"
|
||||
|
||||
MX_REC="$(dig_short windy.me MX || true)"
|
||||
printf 'MX:\n%s\n' "${MX_REC:-<none>}"
|
||||
grep -Fiq "${HOST}" <<<"$MX_REC" || fail "windy.me MX missing ${HOST}"
|
||||
|
||||
SPF="$(dig_short windy.me TXT || true)"
|
||||
printf 'SPF:\n'
|
||||
if ! grep -Fi 'v=spf1' <<<"$SPF"; then
|
||||
fail "no SPF TXT on windy.me"
|
||||
fi
|
||||
|
||||
section "Done"
|
||||
if [[ "$FAILURES" -gt 0 ]]; then
|
||||
echo "Health check finished with ${FAILURES} failure(s)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Health check OK. Update hosts/mx2.windy.me.md Verified line if desired."
|
||||
exit 0
|
||||
Reference in New Issue
Block a user