#!/usr/bin/env bash # CatchupWeb Estate Audit v1.0 # Read-only discovery for Open Line quoting. # Does not install packages, change config, open ports, or scan the network. # Review this file, then: sudo bash estate-audit.sh # Optional: sudo bash estate-audit.sh --out /root/cw-audit set -u CW_VER="1.0" OUT_DIR="" QUIET=0 while [ $# -gt 0 ]; do case "$1" in --out) OUT_DIR="${2:-}"; shift 2 ;; --quiet|-q) QUIET=1; shift ;; -h|--help) echo "Usage: sudo bash estate-audit.sh [--out DIR] [--quiet]" echo "Read-only. Writes JSON + quote sheet. No packages installed." exit 0 ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac done HOST="$(hostname -f 2>/dev/null || hostname)" STAMP="$(date -u +%Y%m%dT%H%M%SZ)" if [ -z "$OUT_DIR" ]; then OUT_DIR="/tmp/cw-estate-audit-${HOST%%.*}-${STAMP}" fi mkdir -p "$OUT_DIR" chmod 700 "$OUT_DIR" JSON="$OUT_DIR/estate.json" QUOTE="$OUT_DIR/quote.txt" RAW="$OUT_DIR/raw.txt" log() { [ "$QUIET" = 1 ] || printf '%s\n' "$*"; } have() { command -v "$1" >/dev/null 2>&1; } # Bound every probe. Never hang a production box. run() { if have timeout; then timeout 4 "$@" 2>/dev/null || true else "$@" 2>/dev/null || true fi } esc() { if command -v python3 >/dev/null 2>&1; then printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()), end="")' else printf '"%s"' "$(printf '%s' "$1" | sed 's/[\\"]/\\&/g')" fi } { echo "=== CatchupWeb Estate Audit $CW_VER ===" echo "host=$HOST stamp=$STAMP user=$(id -un) uid=$(id -u)" } > "$RAW" # ── identity ────────────────────────────────────────────────────── OS_ID="$(run awk -F= '/^ID=/{gsub(/"/,"");print $2}' /etc/os-release)" OS_VER="$(run awk -F= '/^VERSION_ID=/{gsub(/"/,"");print $2}' /etc/os-release)" OS_PRETTY="$(run awk -F= '/^PRETTY_NAME=/{gsub(/"/,"");print $2}' /etc/os-release)" KERNEL="$(run uname -r)" ARCH="$(run uname -m)" VIRT="$(run systemd-detect-virt)" [ -z "$VIRT" ] && VIRT="unknown" if [ -f /.dockerenv ] || grep -qE 'docker|lxc|kubepod' /proc/1/cgroup 2>/dev/null; then VIRT="container" fi # ── capacity ────────────────────────────────────────────────────── CORES="$(run nproc)" [ -z "$CORES" ] && CORES="$(run awk '/^processor/{c++} END{print c+0}' /proc/cpuinfo)" MEM_KB="$(run awk '/MemTotal/{print $2}' /proc/meminfo)" MEM_GB="$(awk -v k="${MEM_KB:-0}" 'BEGIN{printf "%.1f", k/1024/1024}')" SWAP_KB="$(run awk '/SwapTotal/{print $2}' /proc/meminfo)" SWAP_GB="$(awk -v k="${SWAP_KB:-0}" 'BEGIN{printf "%.1f", k/1024/1024}')" LOAD="$(run awk '{print $1","$2","$3}' /proc/loadavg)" UPTIME_S="$(run awk '{print int($1)}' /proc/uptime)" DISK_JSON="[" FIRST=1 while read -r fs size used avail pct mnt; do case "$mnt" in /snap*|/boot/efi|/run*|/dev*|/sys*|/proc*) continue ;; esac [ "$FIRST" = 1 ] || DISK_JSON+="," FIRST=0 DISK_JSON+=$(printf '{"mount":%s,"size":%s,"used":%s,"avail":%s,"pct":%s}' \ "$(esc "$mnt")" "$(esc "$size")" "$(esc "$used")" "$(esc "$avail")" "$(esc "$pct")") done < <(run df -hP -x tmpfs -x devtmpfs -x squashfs -x overlay | awk 'NR>1{print $1,$2,$3,$4,$5,$6}') DISK_JSON+="]" # ── packages / services ─────────────────────────────────────────── PKG_MGR="none" PKG_COUNT=0 if have dpkg-query; then PKG_MGR="apt" PKG_COUNT="$(run dpkg-query -W | wc -l | tr -d ' ')" elif have rpm; then PKG_MGR="rpm" PKG_COUNT="$(run rpm -qa | wc -l | tr -d ' ')" elif have pacman; then PKG_MGR="pacman" PKG_COUNT="$(run pacman -Q | wc -l | tr -d ' ')" fi LAST_PATCH="unknown" if [ -f /var/log/apt/history.log ]; then LAST_PATCH="$(run awk '/^Start-Date:/{d=$2} END{print d}' /var/log/apt/history.log)" elif [ -f /var/log/dnf.log ]; then LAST_PATCH="$(run tail -n 5 /var/log/dnf.log | head -n 1 | awk '{print $1}')" elif [ -f /var/log/yum.log ]; then LAST_PATCH="$(run tail -n 1 /var/log/yum.log | awk '{print $1,$2,$3}')" fi svc_active() { local u="$1" if have systemctl; then run systemctl is-active "$u" | grep -qx active && return 0 fi pgrep -x "$u" >/dev/null 2>&1 } bin_present() { have "$1"; } detect() { # name: returns 1 if found local name="$1"; shift local x for x in "$@"; do case "$x" in svc:*) svc_active "${x#svc:}" && return 0 ;; bin:*) bin_present "${x#bin:}" && return 0 ;; path:*) [ -e "${x#path:}" ] && return 0 ;; esac done return 1 } WP_COUNT=0 if have find; then WP_COUNT="$(run find /var/www /srv /home /opt -maxdepth 5 -type f -name wp-config.php 2>/dev/null | wc -l | tr -d ' ')" fi [ -z "$WP_COUNT" ] && WP_COUNT=0 OLLAMA=0 detect ollama svc:ollama bin:ollama path:/usr/local/bin/ollama && OLLAMA=1 pgrep -f '[o]llama serve' >/dev/null 2>&1 && OLLAMA=1 K8S=0 detect k8s svc:kubelet bin:kubectl path:/etc/kubernetes/admin.conf && K8S=1 DOCKER=0 detect docker svc:docker bin:docker svc:containerd && DOCKER=1 CTR=0 if [ "$DOCKER" = 1 ] && have docker; then CTR="$(run docker ps -q | wc -l | tr -d ' ')" fi [ -z "$CTR" ] && CTR=0 CEPH=0 detect ceph bin:ceph svc:ceph-mon.target svc:ceph-osd.target && CEPH=1 MAIL=0 detect mail svc:postfix svc:dovecot bin:postfix && MAIL=1 NGINX=0; APACHE=0; PG=0; MYSQL=0; PROM=0; GRAF=0 detect nginx svc:nginx bin:nginx && NGINX=1 detect apache svc:apache2 svc:httpd bin:apache2 bin:httpd && APACHE=1 detect postgres svc:postgresql bin:psql path:/var/lib/postgresql && PG=1 run ss -lntH | grep -qE ':5432$' && PG=1 detect mysql svc:mysql svc:mariadb bin:mysqld && MYSQL=1 detect prom svc:prometheus bin:prometheus && PROM=1 detect graf svc:grafana-server bin:grafana-server && GRAF=1 LISTEN="$(run ss -lntH | awk '{n=$4; sub(/.*:/,"",n); if (n ~ /^[0-9]+$/) print n}' | sort -n | uniq | tr '\n' ',' | sed 's/,$//')" if [ -z "$LISTEN" ]; then LISTEN="$(run ss -lntH | awk '/LISTEN/ {n=$5; sub(/.*:/,"",n); if (n ~ /^[0-9]+$/) print n}' | sort -n | uniq | tr '\n' ',' | sed 's/,$//')" fi UNITS_N=0 if have systemctl; then UNITS_N="$(run systemctl list-units --type=service --state=running --no-legend --no-pager | wc -l | tr -d ' ')" fi REBOOT_REQ=0 [ -f /var/run/reboot-required ] && REBOOT_REQ=1 # ── quote map (Open Line catalogue, starting from) ──────────────── NODES=1 INBOX=() ADDONS=() DISTRO_CLASS="other" case "$OS_ID" in ubuntu|debian|raspbian|linuxmint) DISTRO_CLASS="debian-family" ;; rhel|centos|rocky|almalinux|fedora|ol) DISTRO_CLASS="rhel-family" ;; sles|opensuse*) DISTRO_CLASS="suse-family" ;; *) if grep -qi 'raspberry' /proc/device-tree/model 2>/dev/null || grep -qi raspberry /etc/os-release 2>/dev/null; then DISTRO_CLASS="pi" OS_ID="${OS_ID:-raspios}" fi ;; esac if grep -qiE 'raspberry|orange.?pi|armbian' /etc/os-release 2>/dev/null /proc/device-tree/model 2>/dev/null; then DISTRO_CLASS="edge" fi [ "$NGINX" = 1 ] || [ "$APACHE" = 1 ] && INBOX+=("web") [ "$PG" = 1 ] || [ "$MYSQL" = 1 ] && INBOX+=("database") [ "$DOCKER" = 1 ] && INBOX+=("docker") [ "$PROM" = 1 ] || [ "$GRAF" = 1 ] && INBOX+=("observability") [ "$WP_COUNT" -gt 0 ] && ADDONS+=("wordpress:${WP_COUNT}") [ "$MAIL" = 1 ] && ADDONS+=("mail") [ "$K8S" = 1 ] && ADDONS+=("kubernetes") [ "$OLLAMA" = 1 ] && ADDONS+=("private-ai") [ "$CEPH" = 1 ] && ADDONS+=("ceph") [ "$DISTRO_CLASS" = "edge" ] || [ "$DISTRO_CLASS" = "pi" ] && ADDONS+=("edge-device") # Starting-from Open Line prices (ZAR, excl VAT) — single host hint BASE_BENCH=4950 BASE_WORK=9850 BASE_LINE=19500 ADD_WP=1450 ADD_MAIL=1950 ADD_K8S=4950 ADD_AI=4500 ADD_EDGE=2950 ADD_CEPH=6500 ADD_TOTAL=0 HINT="" for a in "${ADDONS[@]+"${ADDONS[@]}"}"; do case "$a" in wordpress:*) HINT+="wordpress "; ADD_TOTAL=$((ADD_TOTAL+ADD_WP)) ;; mail) HINT+="mail "; ADD_TOTAL=$((ADD_TOTAL+ADD_MAIL)) ;; kubernetes) HINT+="k8s "; ADD_TOTAL=$((ADD_TOTAL+ADD_K8S)) ;; private-ai) HINT+="private-ai "; ADD_TOTAL=$((ADD_TOTAL+ADD_AI)) ;; edge-device) HINT+="edge "; ADD_TOTAL=$((ADD_TOTAL+ADD_EDGE)) ;; ceph) HINT+="ceph "; ADD_TOTAL=$((ADD_TOTAL+ADD_CEPH)) ;; esac done # Single node almost always Bench unless k8s/ai/ceph SUGGEST="Open Bench" SUGGEST_ZAR=$((BASE_BENCH+ADD_TOTAL)) if [ "$K8S" = 1 ] || [ "$OLLAMA" = 1 ] || [ "$CEPH" = 1 ]; then SUGGEST="Open Workshop" SUGGEST_ZAR=$((BASE_WORK+ADD_TOTAL)) # Workshop already includes one k8s cluster — don't double-count if [ "$K8S" = 1 ]; then SUGGEST_ZAR=$((SUGGEST_ZAR-ADD_K8S)) HINT="${HINT//k8s /k8s(included-on-workshop) }" fi fi INBOX_CSV="$(IFS=,; echo "${INBOX[*]-}")" ADDONS_CSV="$(IFS=,; echo "${ADDONS[*]-}")" # ── JSON ────────────────────────────────────────────────────────── { printf '{\n' printf ' "tool": "catchupweb-estate-audit",\n' printf ' "version": %s,\n' "$(esc "$CW_VER")" printf ' "generated_utc": %s,\n' "$(esc "$STAMP")" printf ' "hostname": %s,\n' "$(esc "$HOST")" printf ' "user": %s,\n' "$(esc "$(id -un)")" printf ' "privileged": %s,\n' "$([ "$(id -u)" = 0 ] && echo true || echo false)" printf ' "os": {"id":%s,"version":%s,"pretty":%s,"kernel":%s,"arch":%s,"virt":%s,"class":%s},\n' \ "$(esc "$OS_ID")" "$(esc "$OS_VER")" "$(esc "$OS_PRETTY")" "$(esc "$KERNEL")" "$(esc "$ARCH")" "$(esc "$VIRT")" "$(esc "$DISTRO_CLASS")" printf ' "capacity": {"cores":%s,"mem_gb":%s,"swap_gb":%s,"load":%s,"uptime_s":%s},\n' \ "$(esc "${CORES:-0}")" "$(esc "$MEM_GB")" "$(esc "$SWAP_GB")" "$(esc "$LOAD")" "$(esc "${UPTIME_S:-0}")" printf ' "disk": %s,\n' "$DISK_JSON" printf ' "packages": {"manager":%s,"count":%s,"last_patch":%s,"reboot_required":%s},\n' \ "$(esc "$PKG_MGR")" "$(esc "${PKG_COUNT:-0}")" "$(esc "$LAST_PATCH")" "$([ "$REBOOT_REQ" = 1 ] && echo true || echo false)" printf ' "services": {"running_units":%s,"listen_ports":%s},\n' "$(esc "${UNITS_N:-0}")" "$(esc "$LISTEN")" printf ' "stack": {\n' printf ' "nginx": %s, "apache": %s, "postgresql": %s, "mysql": %s,\n' \ "$([ "$NGINX" = 1 ] && echo true || echo false)" \ "$([ "$APACHE" = 1 ] && echo true || echo false)" \ "$([ "$PG" = 1 ] && echo true || echo false)" \ "$([ "$MYSQL" = 1 ] && echo true || echo false)" printf ' "docker": %s, "docker_containers": %s, "kubernetes": %s,\n' \ "$([ "$DOCKER" = 1 ] && echo true || echo false)" "$(esc "$CTR")" \ "$([ "$K8S" = 1 ] && echo true || echo false)" printf ' "wordpress_sites": %s, "mail": %s, "prometheus": %s, "grafana": %s,\n' \ "$(esc "$WP_COUNT")" \ "$([ "$MAIL" = 1 ] && echo true || echo false)" \ "$([ "$PROM" = 1 ] && echo true || echo false)" \ "$([ "$GRAF" = 1 ] && echo true || echo false)" printf ' "ollama": %s, "ceph": %s\n' \ "$([ "$OLLAMA" = 1 ] && echo true || echo false)" \ "$([ "$CEPH" = 1 ] && echo true || echo false)" printf ' },\n' printf ' "open_line": {\n' printf ' "inbox": %s,\n' "$(esc "$INBOX_CSV")" printf ' "addons": %s,\n' "$(esc "$ADDONS_CSV")" printf ' "suggested_package": %s,\n' "$(esc "$SUGGEST")" printf ' "starting_from_zar": %s,\n' "$SUGGEST_ZAR" printf ' "currency": "ZAR",\n' printf ' "note": "Single-host hint. Fleet merge required for estate quote. Prices starting from, excl VAT."\n' printf ' }\n' printf '}\n' } > "$JSON" # ── quote sheet ─────────────────────────────────────────────────── { echo "CatchupWeb Open Line — quote sheet (single host)" echo "Generated: $STAMP Host: $HOST" echo "------------------------------------------------" echo "OS: ${OS_PRETTY:-$OS_ID $OS_VER} ($DISTRO_CLASS)" echo "Kernel: $KERNEL arch=$ARCH virt=$VIRT" echo "Capacity: ${CORES} cores ${MEM_GB}G RAM ${SWAP_GB}G swap load=$LOAD" echo "Disk:" run df -hP -x tmpfs -x devtmpfs -x squashfs | awk 'NR==1 || $6 !~ /^\/(snap|run|sys|proc|dev)/' echo "Packages: $PKG_MGR count=$PKG_COUNT last_patch=$LAST_PATCH reboot_required=$REBOOT_REQ" echo "Listen: $LISTEN" echo "In-box: ${INBOX_CSV:-none}" echo "Add-ons: ${ADDONS_CSV:-none}" echo "------------------------------------------------" echo "Suggested (this host only, starting from, excl VAT):" echo " $SUGGEST starting from R${SUGGEST_ZAR}" echo " Extra hours R1,650 · 10-hour pack R11,500" echo " Merge all host JSON files before issuing an estate quote." echo " Catalogue: https://www.catchupweb.co.za/open-line/" echo "------------------------------------------------" echo "This run was read-only. No packages installed, no config changed." } > "$QUOTE" cp "$JSON" "$RAW.json" 2>/dev/null || true cat "$QUOTE" >> "$RAW" log "CatchupWeb estate audit $CW_VER — read-only" log "Wrote:" log " $JSON" log " $QUOTE" log "" cat "$QUOTE" log "" log "Send both files to CatchupWeb (redact hostnames if needed)." log "Fleet: run on each node, then bash estate-audit-merge.sh reports/*.json" exit 0