a1a17e81a1
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.
522 lines
21 KiB
Bash
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 "$@"
|