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.
411 lines
18 KiB
Bash
Executable File
411 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
#########################################################################################
|
|
#### container-update-checker.sh — Check Docker/Podman containers for image updates ####
|
|
#### Compares local image digests against remote registry digests ####
|
|
#### ####
|
|
#### Author: Phil Connor ####
|
|
#### Contact: contact@mylinux.work ####
|
|
#### License: MIT ####
|
|
#### Version 1.00 ####
|
|
#### ####
|
|
#### Usage: ####
|
|
#### ./container-update-checker.sh ####
|
|
#### ./container-update-checker.sh --docker --filter nginx ####
|
|
#### ./container-update-checker.sh --json --quiet ####
|
|
#### ####
|
|
#### See --help for all options. ####
|
|
#########################################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────
|
|
RUNTIME="${CONTAINER_RUNTIME:-auto}"
|
|
TIMEOUT="${REGISTRY_TIMEOUT:-10}"
|
|
VERBOSE="${VERBOSE:-false}"
|
|
COLOR="${COLOR:-auto}"
|
|
JSON_OUTPUT="false"
|
|
QUIET="false"
|
|
FILTER=""
|
|
LABEL=""
|
|
TEXTFILE_DIR="/var/lib/node_exporter"
|
|
PROM_FILE=""
|
|
|
|
# ── State ─────────────────────────────────────────────────────────────
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
readonly SCRIPT_NAME
|
|
COUNT_CURRENT=0
|
|
COUNT_UPDATE=0
|
|
COUNT_ERROR=0
|
|
COUNT_TOTAL=0
|
|
JSON_ITEMS=""
|
|
PROM_LINES=""
|
|
|
|
# ── Colors ────────────────────────────────────────────────────────────
|
|
setup_colors() {
|
|
if [[ "$COLOR" == "never" ]]; then
|
|
RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET=""
|
|
return
|
|
fi
|
|
if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
|
|
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
|
BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m'
|
|
else
|
|
RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET=""
|
|
fi
|
|
}
|
|
|
|
# ── Logging ───────────────────────────────────────────────────────────
|
|
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
|
|
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
|
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*" >&2; fi; }
|
|
|
|
# ── Runtime Detection ─────────────────────────────────────────────────
|
|
detect_runtime() {
|
|
if [[ "$RUNTIME" == "docker" || "$RUNTIME" == "podman" ]]; then
|
|
if ! command -v "$RUNTIME" &>/dev/null; then
|
|
err "${RUNTIME^} not found"; exit 2
|
|
fi
|
|
return
|
|
fi
|
|
if command -v docker &>/dev/null && docker info &>/dev/null; then
|
|
RUNTIME="docker"
|
|
elif command -v podman &>/dev/null; then
|
|
RUNTIME="podman"
|
|
else
|
|
err "Neither Docker nor Podman found"; exit 2
|
|
fi
|
|
verbose "Auto-detected runtime: ${RUNTIME}"
|
|
}
|
|
|
|
# ── Auth Helper ───────────────────────────────────────────────────────
|
|
get_auth_header() {
|
|
local registry="$1" config_file=""
|
|
if [[ "$RUNTIME" == "podman" ]]; then
|
|
config_file="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/containers/auth.json"
|
|
[[ -f "$config_file" ]] || config_file="${HOME}/.config/containers/auth.json"
|
|
fi
|
|
[[ -f "${config_file:-}" ]] || config_file="${HOME}/.docker/config.json"
|
|
[[ -f "$config_file" ]] || return 0
|
|
local auth
|
|
auth=$(grep -A1 "\"${registry}\"" "$config_file" 2>/dev/null \
|
|
| grep '"auth"' | head -1 | sed 's/.*"auth"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') || true
|
|
if [[ -n "$auth" ]]; then
|
|
echo "Authorization: Basic ${auth}"
|
|
fi
|
|
}
|
|
|
|
# ── Parse Image Reference ────────────────────────────────────────────
|
|
parse_image_ref() {
|
|
local image="$1" registry="" path="" tag=""
|
|
local without_tag="${image%%@*}"
|
|
if [[ "$without_tag" == *:* && "${without_tag##*:}" != */* ]]; then
|
|
tag="${without_tag##*:}"
|
|
without_tag="${without_tag%:*}"
|
|
fi
|
|
[[ -z "$tag" ]] && tag="latest"
|
|
if [[ "$without_tag" == *"."*"/"* ]] || [[ "$without_tag" == *":"*"/"* ]] || [[ "$without_tag" == "localhost/"* ]]; then
|
|
registry="${without_tag%%/*}"
|
|
path="${without_tag#*/}"
|
|
else
|
|
registry="docker.io"
|
|
[[ "$without_tag" == *"/"* ]] && path="$without_tag" || path="library/${without_tag}"
|
|
fi
|
|
echo "${registry}" "${path}" "${tag}"
|
|
}
|
|
|
|
# ── Get Local Digest ─────────────────────────────────────────────────
|
|
get_local_digest() {
|
|
local image="$1" digest
|
|
digest=$($RUNTIME image inspect "$image" --format '{{index .RepoDigests 0}}' 2>/dev/null) || true
|
|
if [[ -n "$digest" && "$digest" == *"@"* ]]; then
|
|
echo "${digest##*@}"; return
|
|
fi
|
|
digest=$($RUNTIME image inspect "$image" --format '{{.Id}}' 2>/dev/null) || true
|
|
echo "${digest:-}"
|
|
}
|
|
|
|
# ── Extract JSON Value (pure bash, no python/jq) ─────────────────────
|
|
json_value() {
|
|
local key="$1"
|
|
sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1
|
|
}
|
|
|
|
# ── Get Remote Digest via Skopeo ──────────────────────────────────────
|
|
get_remote_digest_skopeo() {
|
|
local registry="$1" path="$2" tag="$3"
|
|
local digest
|
|
digest=$(timeout "$TIMEOUT" skopeo inspect --no-tags "docker://${registry}/${path}:${tag}" 2>/dev/null \
|
|
| json_value "Digest") || true
|
|
echo "${digest:-}"
|
|
}
|
|
|
|
# ── Get Remote Digest via Curl ────────────────────────────────────────
|
|
get_remote_digest_curl() {
|
|
local registry="$1" path="$2" tag="$3"
|
|
local token="" digest=""
|
|
if [[ "$registry" == "docker.io" || "$registry" == "registry-1.docker.io" ]]; then
|
|
token=$(curl -sf --max-time "$TIMEOUT" \
|
|
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:${path}:pull" \
|
|
| json_value "token") || true
|
|
[[ -z "$token" ]] && return
|
|
digest=$(curl -sf --max-time "$TIMEOUT" \
|
|
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
|
|
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
|
-H "Authorization: Bearer ${token}" \
|
|
"https://registry-1.docker.io/v2/${path}/manifests/${tag}" \
|
|
-o /dev/null -D - 2>/dev/null \
|
|
| grep -i "docker-content-digest" | tr -d '\r' | awk '{print $2}') || true
|
|
else
|
|
local auth_hdr auth_args=()
|
|
auth_hdr=$(get_auth_header "$registry")
|
|
[[ -n "$auth_hdr" ]] && auth_args=(-H "$auth_hdr")
|
|
digest=$(curl -sf --max-time "$TIMEOUT" \
|
|
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
|
|
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
|
"${auth_args[@]+"${auth_args[@]}"}" \
|
|
"https://${registry}/v2/${path}/manifests/${tag}" \
|
|
-o /dev/null -D - 2>/dev/null \
|
|
| grep -i "docker-content-digest" | tr -d '\r' | awk '{print $2}') || true
|
|
fi
|
|
echo "${digest:-}"
|
|
}
|
|
# ── Get Remote Digest (skopeo then curl fallback) ─────────────────────
|
|
get_remote_digest() {
|
|
local registry="$1" path="$2" tag="$3" digest=""
|
|
if command -v skopeo &>/dev/null; then
|
|
verbose "Trying skopeo for ${registry}/${path}:${tag}"
|
|
digest=$(get_remote_digest_skopeo "$registry" "$path" "$tag")
|
|
fi
|
|
if [[ -z "$digest" ]]; then
|
|
verbose "Trying curl fallback for ${registry}/${path}:${tag}"
|
|
digest=$(get_remote_digest_curl "$registry" "$path" "$tag")
|
|
fi
|
|
echo "${digest:-}"
|
|
}
|
|
|
|
# ── Check Single Container ────────────────────────────────────────────
|
|
check_container() {
|
|
local name="$1" image="$2"
|
|
local status="" local_digest="" remote_digest="" registry path tag
|
|
read -r registry path tag <<< "$(parse_image_ref "$image")"
|
|
verbose "Container=${name} image=${image} registry=${registry} path=${path} tag=${tag}"
|
|
local_digest=$(get_local_digest "$image")
|
|
verbose "Local digest: ${local_digest:-none}"
|
|
if [[ -z "$local_digest" ]]; then
|
|
status="error"
|
|
else
|
|
remote_digest=$(get_remote_digest "$registry" "$path" "$tag")
|
|
verbose "Remote digest: ${remote_digest:-none}"
|
|
if [[ -z "$remote_digest" ]]; then
|
|
status="error"
|
|
elif [[ "$local_digest" == "$remote_digest" ]]; then
|
|
status="current"
|
|
else
|
|
status="update"
|
|
fi
|
|
fi
|
|
COUNT_TOTAL=$((COUNT_TOTAL + 1))
|
|
case "$status" in
|
|
current) COUNT_CURRENT=$((COUNT_CURRENT + 1)) ;;
|
|
update) COUNT_UPDATE=$((COUNT_UPDATE + 1)) ;;
|
|
error) COUNT_ERROR=$((COUNT_ERROR + 1)) ;;
|
|
esac
|
|
if [[ -n "$PROM_FILE" ]]; then
|
|
local val=1; [[ "$status" == "update" ]] && val=0
|
|
PROM_LINES+="container_image_up_to_date{name=\"${name}\",image=\"${image}\"} ${val}"$'\n'
|
|
fi
|
|
[[ "$QUIET" == "true" && "$status" != "update" ]] && return
|
|
if [[ "$JSON_OUTPUT" == "true" ]]; then
|
|
local item
|
|
item=$(printf '{"container":"%s","image":"%s","status":"%s"}' "$name" "$image" "$status")
|
|
[[ -n "$JSON_ITEMS" ]] && JSON_ITEMS="${JSON_ITEMS},${item}" || JSON_ITEMS="${item}"
|
|
else
|
|
local color symbol
|
|
case "$status" in
|
|
current) color="$GREEN"; symbol="up-to-date" ;;
|
|
update) color="$YELLOW"; symbol="update available" ;;
|
|
error) color="$RED"; symbol="check failed" ;;
|
|
*) color=""; symbol="?" ;;
|
|
esac
|
|
printf " %-30s %-40s %b%s%b\n" "$name" "$image" "$color" "$symbol" "$RESET"
|
|
fi
|
|
}
|
|
|
|
# ── List Containers ───────────────────────────────────────────────────
|
|
list_containers() {
|
|
local filter_args=()
|
|
[[ -n "$LABEL" ]] && filter_args+=(--filter "label=${LABEL}")
|
|
$RUNTIME ps --format '{{.Names}}\t{{.Image}}' "${filter_args[@]}" 2>/dev/null
|
|
}
|
|
|
|
# ── Write Prometheus Metrics ──────────────────────────────────────────
|
|
write_prom_metrics() {
|
|
local file="$1"
|
|
local output_dir
|
|
output_dir="$(dirname "$file")"
|
|
mkdir -p "$output_dir"
|
|
local tmp
|
|
tmp=$(mktemp "${output_dir}/.container_updates.XXXXXX")
|
|
{
|
|
echo "# HELP container_image_up_to_date Whether the container image is up to date (1=yes, 0=no)"
|
|
echo "# TYPE container_image_up_to_date gauge"
|
|
printf '%s' "$PROM_LINES"
|
|
echo "# HELP container_update_check_timestamp Unix timestamp of last update check"
|
|
echo "# TYPE container_update_check_timestamp gauge"
|
|
echo "container_update_check_timestamp $(date +%s)"
|
|
echo "# HELP container_update_check_total Total containers checked"
|
|
echo "# TYPE container_update_check_total gauge"
|
|
echo "container_update_check_total ${COUNT_TOTAL}"
|
|
echo "# HELP container_update_available_total Containers with updates available"
|
|
echo "# TYPE container_update_available_total gauge"
|
|
echo "container_update_available_total ${COUNT_UPDATE}"
|
|
} > "$tmp"
|
|
chmod 644 "$tmp"
|
|
mv -f "$tmp" "$file"
|
|
verbose "Metrics written to ${file}"
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# USAGE
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
${SCRIPT_NAME} — Check Docker/Podman containers for available image updates
|
|
|
|
USAGE:
|
|
${SCRIPT_NAME} [OPTIONS]
|
|
|
|
OPTIONS:
|
|
--docker Force Docker as runtime
|
|
--podman Force Podman as runtime
|
|
--filter NAME Only check containers matching name pattern
|
|
--label KEY=VALUE Only check containers with matching label
|
|
--json Output results in JSON format
|
|
--quiet Only show containers with available updates
|
|
--no-color Disable colored output
|
|
--textfile Write metrics to node_exporter textfile collector
|
|
-o, --output PATH Write metrics to custom file path
|
|
--timeout SECONDS Registry check timeout (default: ${TIMEOUT})
|
|
--verbose Enable debug output
|
|
--help Show this help
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
CONTAINER_RUNTIME Force runtime: docker or podman (default: auto)
|
|
REGISTRY_TIMEOUT Registry check timeout in seconds (default: 10)
|
|
COLOR Color mode: auto, always, never (default: auto)
|
|
VERBOSE Enable debug output (true/false)
|
|
|
|
EXAMPLES:
|
|
# Check all running containers
|
|
./${SCRIPT_NAME}
|
|
|
|
# Force Docker, filter by name
|
|
./${SCRIPT_NAME} --docker --filter nginx
|
|
|
|
# JSON output, quiet mode
|
|
./${SCRIPT_NAME} --json --quiet
|
|
|
|
# Write Prometheus metrics to textfile collector
|
|
./${SCRIPT_NAME} --textfile
|
|
|
|
# Write Prometheus metrics to custom path
|
|
./${SCRIPT_NAME} -o /tmp/container_updates.prom
|
|
EOF
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# ARGUMENT PARSING
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--docker) RUNTIME="docker"; shift ;;
|
|
--podman) RUNTIME="podman"; shift ;;
|
|
--filter) FILTER="$2"; shift 2 ;;
|
|
--label) LABEL="$2"; shift 2 ;;
|
|
--json) JSON_OUTPUT="true"; shift ;;
|
|
--quiet) QUIET="true"; shift ;;
|
|
--no-color) COLOR="never"; shift ;;
|
|
--textfile) PROM_FILE="$TEXTFILE_DIR/container_updates.prom"; shift ;;
|
|
-o|--output) PROM_FILE="$2"; shift 2 ;;
|
|
--timeout) TIMEOUT="$2"; shift 2 ;;
|
|
--verbose) VERBOSE="true"; shift ;;
|
|
--help|-h) setup_colors; usage; exit 0 ;;
|
|
-*)
|
|
err "Unknown option: $1"
|
|
echo "Run ${SCRIPT_NAME} --help for usage" >&2
|
|
exit 2 ;;
|
|
*)
|
|
err "Unexpected argument: $1"
|
|
echo "Run ${SCRIPT_NAME} --help for usage" >&2
|
|
exit 2 ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# MAIN
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
setup_colors
|
|
detect_runtime
|
|
|
|
local containers=()
|
|
while IFS=$'\t' read -r name image; do
|
|
[[ -z "$name" ]] && continue
|
|
[[ -n "$FILTER" && "$name" != *"${FILTER}"* ]] && continue
|
|
containers+=("${name} ${image}")
|
|
done < <(list_containers)
|
|
|
|
if [[ ${#containers[@]} -eq 0 ]]; then
|
|
if [[ "$JSON_OUTPUT" == "true" ]]; then
|
|
echo '{"results":[],"summary":{"total":0,"current":0,"update_available":0,"errors":0}}'
|
|
else
|
|
warn "No running containers found"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
verbose "Found ${#containers[@]} containers to check"
|
|
|
|
if [[ "$JSON_OUTPUT" != "true" ]]; then
|
|
echo ""
|
|
echo -e "${BOLD}Container Update Checker${RESET}"
|
|
echo -e "${DIM}Runtime: ${RUNTIME} | Timeout: ${TIMEOUT}s${RESET}"
|
|
echo ""
|
|
printf " ${BOLD}%-30s %-40s %s${RESET}\n" "CONTAINER" "IMAGE" "STATUS"
|
|
printf " %s\n" "$(printf '%.0s─' {1..82})"
|
|
fi
|
|
|
|
for entry in "${containers[@]}"; do
|
|
check_container "${entry%% *}" "${entry#* }"
|
|
done
|
|
|
|
if [[ "$JSON_OUTPUT" == "true" ]]; then
|
|
printf '{"results":[%s],"summary":{"total":%d,"current":%d,"update_available":%d,"errors":%d}}\n' \
|
|
"$JSON_ITEMS" "$COUNT_TOTAL" "$COUNT_CURRENT" "$COUNT_UPDATE" "$COUNT_ERROR"
|
|
else
|
|
echo ""
|
|
echo -e " ${BOLD}Summary${RESET}"
|
|
printf " %-20s %d\n" "Total checked:" "$COUNT_TOTAL"
|
|
printf " %-20s %b%d%b\n" "Up-to-date:" "$GREEN" "$COUNT_CURRENT" "$RESET"
|
|
printf " %-20s %b%d%b\n" "Update available:" "$YELLOW" "$COUNT_UPDATE" "$RESET"
|
|
printf " %-20s %b%d%b\n" "Errors:" "$RED" "$COUNT_ERROR" "$RESET"
|
|
echo ""
|
|
fi
|
|
|
|
[[ -n "$PROM_FILE" ]] && write_prom_metrics "$PROM_FILE"
|
|
|
|
if [[ "$COUNT_ERROR" -gt 0 ]]; then exit 2
|
|
elif [[ "$COUNT_UPDATE" -gt 0 ]]; then exit 1
|
|
fi
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|