#!/bin/bash set -euo pipefail # Pre-migration audit for a cPanel/WHM server: account totals, nameserver # locality, custom reseller nameservers, and accounts on dedicated IPs. for cmd in whmapi1 jq dig; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "Error: required command '$cmd' not found." >&2 exit 1 fi done if [ "$(id -u)" -ne 0 ]; then echo "Error: this script must be run as root." >&2 exit 1 fi whm_call() { local json json=$(whmapi1 --output=json "$@") if [ "$(jq -r '.metadata.result' <<<"$json")" != "1" ]; then echo "Error: whmapi1 $1 failed: $(jq -r '.metadata.reason' <<<"$json")" >&2 exit 1 fi printf '%s' "$json" } acct_json=$(whm_call listaccts want=user,domain,owner,ip,diskused) ns_json=$(whm_call get_nameserver_config) ip_json=$(whm_call listips) printf 'Accounts: %s\nCustomer data: %.2f GiB\nServer nameservers:\n' \ "$(jq '.data.acct | length' <<<"$acct_json")" \ "$(jq '[.data.acct[].diskused | sub("M$";"") | (tonumber? // 0)] | (add // 0) / 1024' <<<"$acct_json")" resolve_host() { { dig +short A "$1" || true dig +short AAAA "$1" || true } | sed 's/\.$//' | { grep -E '^[0-9A-Fa-f.:]+$' || true; } | sort -u } # IPv4 comes from the WHM IP inventory (includes public IPs on NAT'd # servers); listips is IPv4-only, so IPv6 still comes from the kernel. server_ips=$( { jq -r '.data.ip[]?.ip' <<<"$ip_json" ip -o -6 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 || true } | sed '/^$/d' | sort -u ) # Our global DNS cluster (StableDNS) is reachable under two brand # names; both resolve to the same cluster IPs. stabledns_ips=$( for i in 1 2 3 4; do resolve_host "ns${i}.stableserver.net" resolve_host "ns${i}.mysecurecloudhost.com" done | sort -u ) jq -r '.data.nameservers[]?' <<<"$ns_json" | while read -r ns; do resolved=$(resolve_host "$ns") if [ -z "$resolved" ]; then status="UNRESOLVED" elif grep -Fqx -f \ <(printf '%s\n' "$server_ips") \ <(printf '%s\n' "$resolved"); then status="LOCAL" elif [ -n "$stabledns_ips" ] && grep -Fqx -f \ <(printf '%s\n' "$stabledns_ips") \ <(printf '%s\n' "$resolved"); then status="STABLEDNS" else status="REMOTE/CLUSTER" fi printf ' %-35s %-15s %s\n' \ "$ns" \ "$status" \ "$(paste -sd, <<<"$resolved")" done # No WHM API getter exists for reseller nameservers (only # setresellernameservers), so read cPanel's backing file directly. custom_reseller_ns=$( awk -F: '{ ns=$2 gsub(/[[:space:]]/, "", ns) # The file has four fixed slots, so collapse the empty entries # left behind by unused slots (e.g. "ns1,ns2,,"). gsub(/,+/, ",", ns) sub(/^,/, "", ns) sub(/,$/, "", ns) if (ns != "") printf " %s: %s\n", $1, ns }' /var/cpanel/resellers-nameservers 2>/dev/null || true ) # One line per reseller, so the line count is the reseller count. if [ -n "$custom_reseller_ns" ]; then custom_reseller_ns_count=$(wc -l <<<"$custom_reseller_ns" | tr -d ' ') else custom_reseller_ns_count=0 fi printf '\nCustom reseller nameservers (%s reseller(s)):\n' \ "$custom_reseller_ns_count" if [ -n "$custom_reseller_ns" ]; then printf '%s\n' "$custom_reseller_ns" else printf ' None\n' fi # listips only flags an IP as "dedicated" when a single account uses it, # which misses IPs delegated to resellers as their shared IP. Instead, # report every account that is not on the server's main shared IP and # classify how its IP is used. server_shared_ip=$(whm_call get_shared_ip | jq -r '.data.ip // empty') if [ -z "$server_shared_ip" ] || [ "$server_shared_ip" = "~" ]; then # listips can flag several addresses as main on multi-interface # servers; only ever use one so later exact-match comparisons work. server_shared_ip=$( jq -r 'first(.data.ip[]? | select((.mainaddr | tonumber) == 1) | .ip) // empty' <<<"$ip_json" ) fi # Map of IP -> list of resellers using it as their shared IP. An IP # with multiple resellers is effectively a shared pool IP. reseller_shared_map=$( whm_call listresellers | jq -r '.data.reseller[]?' | while read -r reseller; do reseller_ip=$(whm_call get_shared_ip user="$reseller" | jq -r '.data.ip // empty') if [ -n "$reseller_ip" ] && [ "$reseller_ip" != "~" ] && [ "$reseller_ip" != "$server_shared_ip" ]; then printf '%s\t%s\n' "$reseller_ip" "$reseller" fi done | jq -Rn ' [inputs | split("\t") | {ip: .[0], reseller: .[1]}] | group_by(.ip) | map({key: .[0].ip, value: map(.reseller)}) | from_entries ' ) # Grouped by IP: an "H" line per qualifying IP followed by an "A" line # per account on it. The account JSON is streamed via stdin because it # can exceed the kernel argument-size limit on large servers. dedicated_ip_groups=$( jq -r \ --argjson reseller_shared "$reseller_shared_map" \ --arg shared_ip "$server_shared_ip" ' [ .data.acct[] | select(.ip != $shared_ip) | ($reseller_shared[.ip] // []) as $resellers # IPs shared between multiple resellers are treated as shared IPs # and skipped, like the main shared IP. | select(($resellers | length) <= 1) ] | group_by(.ip)[] | ($reseller_shared[.[0].ip] // []) as $resellers # With no reseller attached, the IP only counts as dedicated when a # single account uses it. | select(($resellers | length) == 1 or length == 1) | ( [ "H", .[0].ip, (if ($resellers | length) == 1 then "reseller-shared (" + $resellers[0] + ")" else "dedicated (owner: " + .[0].owner + ")" end), (length | tostring) ], (.[] | ["A", .user, .domain]) ) | @tsv ' <<<"$acct_json" ) if [ -n "$dedicated_ip_groups" ]; then dedicated_ip_count=$(grep -c '^H' <<<"$dedicated_ip_groups") else dedicated_ip_count=0 fi printf '\nDedicated IPs (single account or single reseller): %s\n' \ "$dedicated_ip_count" if [ "$dedicated_ip_count" -gt 0 ]; then while IFS=$'\t' read -r tag field1 field2 field3; do case "$tag" in H) printf '\n %s %s (%s account(s))\n' "$field1" "$field2" "$field3" printf ' %-16s %s\n' 'USER' 'DOMAIN' ;; A) printf ' %-16s %s\n' "$field1" "$field2" ;; esac done <<<"$dedicated_ip_groups" else printf ' None\n' fi