#!/usr/bin/env bash ######################################################################################### #### contabo-backup-auditor.sh — Audit snapshot ages and backup coverage for #### #### Contabo VPS/VDS instances via the REST API #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./contabo-backup-auditor.sh --audit #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Colors (pre-initialized) ───────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR:-auto}" == "never" ]]; then return fi if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } elapsed() { local end_time end_time=$(date +%s) echo "$(( end_time - START_TIME ))s" } # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" INSTANCE_ID="" TAG_ID="" OUTPUT_FORMAT="${CBA_FORMAT:-table}" MAX_AGE_HOURS="${CBA_MAX_AGE:-48}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── Credentials ─────────────────────────────────────────────────────── CONTABO_CLIENT_ID="${CONTABO_CLIENT_ID:-}" CONTABO_CLIENT_SECRET="${CONTABO_CLIENT_SECRET:-}" CONTABO_API_USER="${CONTABO_API_USER:-}" CONTABO_API_PASS="${CONTABO_API_PASS:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── API helpers ────────────────────────────────────────────────────── contabo_token() { local resp resp=$(curl -s -d "client_id=${CONTABO_CLIENT_ID}" \ -d "client_secret=${CONTABO_CLIENT_SECRET}" \ --data-urlencode "username=${CONTABO_API_USER}" \ --data-urlencode "password=${CONTABO_API_PASS}" \ -d "grant_type=password" \ "https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token") local token token=$(echo "$resp" | jq -r '.access_token // empty' 2>/dev/null) if [[ -z "$token" ]]; then die "Failed to obtain access token — check credentials" fi echo "$token" } contabo_api() { local method="$1" endpoint="$2" shift 2 local attempt=0 max_attempts=3 while (( attempt < max_attempts )); do local http_code http_code=$(curl -s -o /tmp/cba_resp.json -w "%{http_code}" \ -X "$method" \ -H "Authorization: Bearer $(contabo_token)" \ -H "Content-Type: application/json" \ -H "x-request-id: $(cat /proc/sys/kernel/random/uuid 2>/dev/null || date +%s%N)" \ "https://api.contabo.com/v1${endpoint}" "$@") verbose "API ${method} ${endpoint} → HTTP ${http_code}" if [[ "$http_code" == "429" ]]; then ((attempt++)) || true local wait=$(( attempt * 5 )) warn "Rate limited — retrying in ${wait}s (attempt ${attempt}/${max_attempts})" sleep "$wait" continue fi cat /tmp/cba_resp.json return 0 done err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}" return 1 } check_credentials() { [[ -z "$CONTABO_CLIENT_ID" ]] && die "CONTABO_CLIENT_ID not set" [[ -z "$CONTABO_CLIENT_SECRET" ]] && die "CONTABO_CLIENT_SECRET not set" [[ -z "$CONTABO_API_USER" ]] && die "CONTABO_API_USER not set" [[ -z "$CONTABO_API_PASS" ]] && die "CONTABO_API_PASS not set" } check_deps() { command -v curl &>/dev/null || die "curl is required" command -v jq &>/dev/null || die "jq is required" } # ── Pagination helper ──────────────────────────────────────────────── fetch_all_contabo() { local endpoint="$1" key="$2" local page=1 size=100 all_data="[]" while true; do local sep="?" [[ "$endpoint" == *"?"* ]] && sep="&" local resp resp=$(contabo_api GET "${endpoint}${sep}page=${page}&size=${size}") local page_data page_data=$(echo "$resp" | jq ".${key} // []" 2>/dev/null) local page_count page_count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0) [[ "$page_count" -eq 0 ]] && break all_data=$(echo -e "${all_data}\n${page_data}" | jq -s 'add' 2>/dev/null) (( page_count < size )) && break ((page++)) || true done echo "$all_data" } # ── Age helpers ────────────────────────────────────────────────────── iso_to_epoch() { date -d "$1" +%s 2>/dev/null || echo 0 } age_hours() { local created_epoch="$1" local now now=$(date +%s) echo $(( (now - created_epoch) / 3600 )) } format_age() { local hours="$1" if [[ "$hours" -lt 24 ]]; then echo "${hours}h" else local days=$(( hours / 24 )) local rem=$(( hours % 24 )) echo "${days}d ${rem}h" fi } # ══════════════════════════════════════════════════════════════════════ # AUDIT # ══════════════════════════════════════════════════════════════════════ do_audit() { local instances instances=$(fetch_all_contabo "/compute/instances" "data") local instance_count instance_count=$(echo "$instances" | jq 'length' 2>/dev/null || echo 0) [[ "$instance_count" -eq 0 ]] && die "No instances found" # Filter by tag if specified if [[ -n "$TAG_ID" ]]; then instances=$(echo "$instances" | jq --arg tid "$TAG_ID" \ '[.[] | select(.tags[]? | .tagId == ($tid | tonumber))]' 2>/dev/null) instance_count=$(echo "$instances" | jq 'length' 2>/dev/null || echo 0) [[ "$instance_count" -eq 0 ]] && die "No instances found with tag ${TAG_ID}" fi # Filter by instance ID if [[ -n "$INSTANCE_ID" ]]; then instances=$(echo "$instances" | jq --arg iid "$INSTANCE_ID" \ '[.[] | select(.instanceId == ($iid | tonumber))]' 2>/dev/null) instance_count=$(echo "$instances" | jq 'length' 2>/dev/null || echo 0) [[ "$instance_count" -eq 0 ]] && die "Instance not found: ${INSTANCE_ID}" fi local snapshots snapshots=$(fetch_all_contabo "/compute/snapshots" "data") local warnings=0 local no_backup=0 local stale=0 local healthy=0 local results="" while IFS=$'\t' read -r iid iname istatus; do [[ -z "$iid" ]] && continue # Find most recent snapshot for this instance local latest_snap latest_snap=$(echo "$snapshots" | jq -r \ --arg iid "$iid" \ '[.[] | select(.instanceId == ($iid | tonumber))] | sort_by(.createdDate) | last | .createdDate // empty' \ 2>/dev/null) local age_h="—" local status_flag="none" if [[ -n "$latest_snap" ]]; then local nepoch nepoch=$(iso_to_epoch "$latest_snap") age_h=$(age_hours "$nepoch") if [[ "$age_h" -le "$MAX_AGE_HOURS" ]]; then status_flag="ok" ((healthy++)) || true else status_flag="stale" ((stale++)) || true ((warnings++)) || true fi else ((no_backup++)) || true ((warnings++)) || true fi # Count snapshots for this instance local snap_count snap_count=$(echo "$snapshots" | jq --arg iid "$iid" \ '[.[] | select(.instanceId == ($iid | tonumber))] | length' 2>/dev/null || echo 0) results="${results}${iid}\t${iname}\t${istatus}\t${snap_count}\t${age_h}\t${status_flag}\n" done < <(echo "$instances" | jq -r \ '.[] | "\(.instanceId)\t\(.name // .displayName // "unknown")\t\(.status // "—")"' \ 2>/dev/null) case "$OUTPUT_FORMAT" in json) jq -n \ --argjson instances "$instance_count" \ --argjson healthy "$healthy" \ --argjson stale "$stale" \ --argjson no_backup "$no_backup" \ --argjson warnings "$warnings" \ --argjson max_age "$MAX_AGE_HOURS" \ '{instances: $instances, healthy: $healthy, stale: $stale, no_backup: $no_backup, warnings: $warnings, max_age_hours: $max_age}' ;; prometheus) cat </dev/null || echo 0) [[ "$total" -eq 0 ]] && die "No snapshots found" case "$OUTPUT_FORMAT" in json) echo "$snapshots" | jq '[.[] | { id: (.snapshotId // .id), name: .name, instance_id: .instanceId, created: .createdDate }]' ;; prometheus) local stale_count=0 while IFS=$'\t' read -r sid screated; do [[ -z "$sid" ]] && continue local cepoch cepoch=$(iso_to_epoch "$screated") local ah ah=$(age_hours "$cepoch") [[ "$ah" -gt "$MAX_AGE_HOURS" ]] && ((stale_count++)) || true done < <(echo "$snapshots" | jq -r '.[] | "\(.snapshotId // .id)\t\(.createdDate // "")"' 2>/dev/null) cat </dev/null \ | while IFS=$'\t' read -r sid sname siid screated; do local cepoch ah age_display age_color cepoch=$(iso_to_epoch "$screated") ah=$(age_hours "$cepoch") age_display=$(format_age "$ah") age_color="$GREEN" [[ "$ah" -gt "$MAX_AGE_HOURS" ]] && age_color="$YELLOW" printf " %-38s %-18s %-10s %-20s " \ "${sid:0:36}" "${sname:0:16}" "$siid" "${screated:0:19}" echo -e "${age_color}${age_display}${RESET}" done echo "" field "Snapshots:" "$total" ;; esac } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat < /var/lib/node_exporter/textfile/contabo_backup.prom 2>/dev/null ${BOLD}EXIT CODES${RESET} 0 Success 1 Runtime error EOF } # ══════════════════════════════════════════════════════════════════════ # PARSE ARGS # ══════════════════════════════════════════════════════════════════════ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --audit) RUN_MODE="audit"; shift ;; --snapshots) RUN_MODE="snapshots"; shift ;; --instance) INSTANCE_ID="${2:?--instance requires an ID}"; shift 2 ;; --tag) TAG_ID="${2:?--tag requires a TAG_ID}"; shift 2 ;; --max-age) MAX_AGE_HOURS="${2:?--max-age requires HOURS}"; shift 2 ;; --format) OUTPUT_FORMAT="${2:?--format requires a value}"; shift 2 ;; --verbose) VERBOSE="true"; shift ;; --no-color) COLOR="never"; shift ;; --help|-h) setup_colors; show_help; exit 0 ;; *) die "Unknown option: $1 (see --help)" ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors if [[ -z "$RUN_MODE" ]]; then RUN_MODE="audit" fi check_deps check_credentials START_TIME=$(date +%s) case "$RUN_MODE" in audit) do_audit ;; snapshots) do_snapshots ;; *) die "Unknown mode: ${RUN_MODE}" ;; esac if [[ "$OUTPUT_FORMAT" != "prometheus" ]]; then echo "" field "Duration:" "$(elapsed)" fi } main "$@"