Files
linux-scripts/contabo-backup-auditor.sh
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

522 lines
21 KiB
Bash

#!/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 <<EOF
# HELP contabo_backup_instances_total Total instances audited
# TYPE contabo_backup_instances_total gauge
contabo_backup_instances_total ${instance_count}
# HELP contabo_backup_healthy Instances with recent snapshot
# TYPE contabo_backup_healthy gauge
contabo_backup_healthy ${healthy}
# HELP contabo_backup_stale_total Instances with stale snapshot
# TYPE contabo_backup_stale_total gauge
contabo_backup_stale_total ${stale}
# HELP contabo_backup_missing_total Instances with no snapshot
# TYPE contabo_backup_missing_total gauge
contabo_backup_missing_total ${no_backup}
# HELP contabo_backup_warnings_total Total audit warnings
# TYPE contabo_backup_warnings_total gauge
contabo_backup_warnings_total ${warnings}
# HELP contabo_backup_max_age_hours Configured max age threshold
# TYPE contabo_backup_max_age_hours gauge
contabo_backup_max_age_hours ${MAX_AGE_HOURS}
EOF
;;
*)
section_header "Backup Audit (max age: ${MAX_AGE_HOURS}h)"
printf " ${BOLD}%-10s %-20s %-10s %-10s %-8s %-10s${RESET}\n" \
"ID" "NAME" "STATUS" "SNAPSHOTS" "AGE" "RESULT"
printf " %s\n" "$(printf '%.0s─' {1..72})"
echo -e "$results" | while IFS=$'\t' read -r iid iname istatus snap_count age_h status_flag; do
[[ -z "$iid" ]] && continue
local result_color="$GREEN"
local result_text="✓ ok"
case "$status_flag" in
ok) result_color="$GREEN"; result_text="✓ ok" ;;
stale) result_color="$YELLOW"; result_text="⚠ stale" ;;
none) result_color="$RED"; result_text="✗ none" ;;
esac
local age_display="—"
if [[ "$age_h" != "—" ]]; then
age_display=$(format_age "$age_h")
fi
printf " %-10s %-20s %-10s %-10s %-8s " \
"$iid" "${iname:0:18}" "$istatus" "$snap_count" "$age_display"
echo -e "${result_color}${result_text}${RESET}"
done
echo ""
field "Instances:" "$instance_count"
field_color "Healthy:" "${GREEN}${healthy}${RESET}"
if [[ "$stale" -gt 0 ]]; then
field_color "Stale:" "${YELLOW}${stale}${RESET}"
else
field_color "Stale:" "${GREEN}0${RESET}"
fi
if [[ "$no_backup" -gt 0 ]]; then
field_color "No snapshot:" "${RED}${no_backup}${RESET}"
else
field_color "No snapshot:" "${GREEN}0${RESET}"
fi
;;
esac
}
# ══════════════════════════════════════════════════════════════════════
# SNAPSHOTS
# ══════════════════════════════════════════════════════════════════════
do_snapshots() {
local snapshots
snapshots=$(fetch_all_contabo "/compute/snapshots" "data")
local total
total=$(echo "$snapshots" | jq 'length' 2>/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 <<EOF
# HELP contabo_backup_snapshots_total Total snapshots
# TYPE contabo_backup_snapshots_total gauge
contabo_backup_snapshots_total ${total}
# HELP contabo_backup_snapshots_stale Snapshots older than threshold
# TYPE contabo_backup_snapshots_stale gauge
contabo_backup_snapshots_stale ${stale_count}
EOF
;;
*)
section_header "Snapshots"
printf " ${BOLD}%-38s %-18s %-10s %-20s %-8s${RESET}\n" \
"SNAPSHOT_ID" "NAME" "INSTANCE" "CREATED" "AGE"
printf " %s\n" "$(printf '%.0s─' {1..96})"
echo "$snapshots" | jq -r \
'.[] | "\(.snapshotId // .id // "—")\t\(.name // "—")\t\(.instanceId // "—")\t\(.createdDate // "—")"' \
2>/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 <<EOF
${BOLD}${SCRIPT_NAME}${RESET} — Contabo Backup Auditor
Audit snapshot ages and backup coverage for Contabo VPS/VDS instances
via the REST API.
${BOLD}MODES${RESET}
--audit Audit all instances for snapshot coverage (default)
--snapshots List all snapshots with age
${BOLD}TARGETING${RESET}
--instance ID Audit a specific instance
--tag TAG_ID Filter instances by Contabo tag ID
${BOLD}OPTIONS${RESET}
--max-age HOURS Max acceptable snapshot age (default: 48)
--format FMT Output: table, json, prometheus (default: table)
--verbose Debug output
--no-color Disable colored output
--help Show this help message
${BOLD}ENVIRONMENT VARIABLES${RESET}
CONTABO_CLIENT_ID OAuth2 Client ID (required)
CONTABO_CLIENT_SECRET OAuth2 Client Secret (required)
CONTABO_API_USER API username / email (required)
CONTABO_API_PASS API password (required)
CBA_FORMAT Default output format
CBA_MAX_AGE Default max age threshold in hours (default: 48)
VERBOSE Enable verbose output (true/false)
COLOR Color mode: auto, always, never
${BOLD}EXAMPLES${RESET}
# Audit all instances
${SCRIPT_NAME} --audit
# Audit with 24-hour threshold
${SCRIPT_NAME} --audit --max-age 24
# Audit instances by tag
${SCRIPT_NAME} --audit --tag 42
# List all snapshots
${SCRIPT_NAME} --snapshots
# JSON output
${SCRIPT_NAME} --audit --format json
# Prometheus metrics
${SCRIPT_NAME} --audit --format prometheus
# Cron — daily backup audit
0 6 * * * /usr/local/bin/contabo-backup-auditor.sh --audit --format prometheus --no-color > /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 "$@"