#!/usr/bin/env bash # check-containers # # statuses: # online = http response exactly 200 # error = reachable but non-200 response # offline = dns/tls/connectivity/timeout failure # skipped = unlabeled / wildcard / intentionally not checked # # exit codes: # 0 = all checked domains online, or domains-only output # 1 = at least one error # 2 = at least one offline # 3 = runtime/config/dependency error set -u set -o pipefail if [ -n "${ZSH_VERSION:-}" ]; then emulate -L sh fi IFS=' ' PROGRAM="check-containers" VERSION="1.1.1" CONFIG_FILE="${CHECK_CONTAINERS_CONFIG:-/etc/check-containers.conf}" USE_COLOR="${CHECK_CONTAINERS_COLOR:-auto}" DOMAINS_ONLY=0 LABEL_KEY="${CHECK_CONTAINERS_LABEL_KEY:-}" AUTO_DISCOVER="${CHECK_CONTAINERS_AUTO_DISCOVER:-1}" NO_HEADER=0 TIMEOUT="${CHECK_CONTAINERS_TIMEOUT:-8}" FORCE_SCHEME="${CHECK_CONTAINERS_SCHEME:-}" SHOW_ALL="${CHECK_CONTAINERS_SHOW_ALL:-0}" MONITOR=0 REFRESH="${CHECK_CONTAINERS_REFRESH:-10}" OUTPUT_FORMAT="${CHECK_CONTAINERS_OUTPUT:-table}" STRICT_TLS="${CHECK_CONTAINERS_STRICT_TLS:-1}" EDIT_CONFIG=0 INTERACTIVE=0 EXIT_MODE="${CHECK_CONTAINERS_EXIT_MODE:-default}" OS_FAMILY="unknown" OS_NAME="unknown" RESET='' BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' GRAY='' usage() { cat <<'USAGE' usage: check-containers [options] check-containers monitor [options] options: -c, --color force color output -n, --no-color disable color output -d, --domains print domains only --label key docker label key to use --no-discover disable auto-detection of domain label key --no-header do not print table header --timeout seconds http timeout per domain (default: 8) --http check http only --https check https only --all include containers without valid domain labels --refresh seconds monitor refresh interval (default: 10) --json output json --table output table --strict-tls verify tls certificates --insecure ignore tls certificate validation --config file use alternate config file --edit-config edit config interactively with prompts --interactive prompt for missing config values before run --exit-default 0 all online, 1 any error, 2 any offline, 3 runtime failure -h, --help show help commands: monitor live refreshing view config precedence: cli > environment > config file > built-in defaults environment: check_containers_config check_containers_label_key check_containers_auto_discover check_containers_timeout check_containers_scheme check_containers_show_all check_containers_refresh check_containers_output check_containers_strict_tls check_containers_color check_containers_exit_mode examples: check-containers check-containers --label caddy check-containers --domains check-containers --json check-containers monitor --refresh 5 check-containers --edit-config USAGE } die() { printf 'error: %s\n' "$*" >&2 exit 3 } warn() { printf 'warning: %s\n' "$*" >&2 } # a dog wearing shoes is a top 5 out of all the phenomenons need() { command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" } trim() { local value value="${1:-}" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } lower() { printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' } load_config_file() { [ -f "$CONFIG_FILE" ] || return 0 # shellcheck disable=SC1090 . "$CONFIG_FILE" LABEL_KEY="${CHECK_CONTAINERS_LABEL_KEY:-$LABEL_KEY}" AUTO_DISCOVER="${CHECK_CONTAINERS_AUTO_DISCOVER:-$AUTO_DISCOVER}" TIMEOUT="${CHECK_CONTAINERS_TIMEOUT:-$TIMEOUT}" FORCE_SCHEME="${CHECK_CONTAINERS_SCHEME:-$FORCE_SCHEME}" SHOW_ALL="${CHECK_CONTAINERS_SHOW_ALL:-$SHOW_ALL}" REFRESH="${CHECK_CONTAINERS_REFRESH:-$REFRESH}" OUTPUT_FORMAT="${CHECK_CONTAINERS_OUTPUT:-$OUTPUT_FORMAT}" STRICT_TLS="${CHECK_CONTAINERS_STRICT_TLS:-$STRICT_TLS}" USE_COLOR="${CHECK_CONTAINERS_COLOR:-$USE_COLOR}" EXIT_MODE="${CHECK_CONTAINERS_EXIT_MODE:-$EXIT_MODE}" } detect_os() { if [ -r /etc/os-release ]; then OS_NAME="$(awk -F= '/^id=/{gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null | head -n1)" [ -n "$OS_NAME" ] || OS_NAME="unknown" case "$OS_NAME" in alpine) OS_FAMILY="alpine" ;; arch|endeavouros|manjaro) OS_FAMILY="arch" ;; ubuntu) OS_FAMILY="ubuntu" ;; debian|raspbian) OS_FAMILY="debian" ;; *) OS_FAMILY="$OS_NAME" ;; esac return 0 fi if command -v apk >/dev/null 2>&1; then OS_FAMILY="alpine" OS_NAME="alpine" elif command -v pacman >/dev/null 2>&1; then OS_FAMILY="arch" OS_NAME="arch" elif command -v apt-get >/dev/null 2>&1; then OS_FAMILY="debian" OS_NAME="debian" fi } pick_random_theme() { local idx idx=$(( ( $(date +%s) + $$ ) % 5 )) case "$idx" in 0) RED=$'\033[0;31m' GREEN=$'\033[0;32m' YELLOW=$'\033[1;33m' BLUE=$'\033[0;34m' MAGENTA=$'\033[0;35m' CYAN=$'\033[0;36m' GRAY=$'\033[0;90m' ;; 1) RED=$'\033[1;31m' GREEN=$'\033[1;32m' YELLOW=$'\033[0;33m' BLUE=$'\033[1;34m' MAGENTA=$'\033[1;35m' CYAN=$'\033[1;36m' GRAY=$'\033[0;37m' ;; 2) RED=$'\033[38;5;196m' GREEN=$'\033[38;5;46m' YELLOW=$'\033[38;5;226m' BLUE=$'\033[38;5;39m' MAGENTA=$'\033[38;5;201m' CYAN=$'\033[38;5;51m' GRAY=$'\033[38;5;245m' ;; 3) RED=$'\033[38;5;160m' GREEN=$'\033[38;5;34m' YELLOW=$'\033[38;5;178m' BLUE=$'\033[38;5;27m' MAGENTA=$'\033[38;5;129m' CYAN=$'\033[38;5;44m' GRAY=$'\033[38;5;240m' ;; *) RED=$'\033[0;31m' GREEN=$'\033[38;5;70m' YELLOW=$'\033[38;5;214m' BLUE=$'\033[38;5;33m' MAGENTA=$'\033[38;5;141m' CYAN=$'\033[38;5;45m' GRAY=$'\033[38;5;243m' ;; esac } setup_colors() { local tty=0 [ -t 1 ] && tty=1 case "$USE_COLOR" in 1|yes|true|always|force) USE_COLOR="force" ;; 0|no|false|never) USE_COLOR="never" ;; auto|*) USE_COLOR="auto" ;; esac if [ "$USE_COLOR" = "never" ]; then RESET='' BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' GRAY='' return 0 fi if [ "$USE_COLOR" = "force" ] || [ "$tty" -eq 1 ]; then RESET=$'\033[0m' BOLD=$'\033[1m' DIM=$'\033[2m' pick_random_theme else RESET='' BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' GRAY='' fi } prompt_default() { local prompt default answer prompt="${1:-}" default="${2:-}" printf '%s [%s]: ' "$prompt" "$default" >&2 IFS= read -r answer || true answer="$(trim "$answer")" [ -n "$answer" ] || answer="$default" printf '%s' "$answer" } write_config_file() { local dir dir=$(dirname "$CONFIG_FILE") [ -d "$dir" ] || mkdir -p "$dir" || die "cannot create config directory: $dir" cat > "$CONFIG_FILE" <&2 printf '%sdetected os:%s %s (%s)\n' "$CYAN" "$RESET" "$OS_NAME" "$OS_FAMILY" >&2 LABEL_KEY="$(prompt_default "docker label key" "${LABEL_KEY:-caddy}")" AUTO_DISCOVER="$(prompt_default "auto-discover label key (1/0)" "${AUTO_DISCOVER:-1}")" TIMEOUT="$(prompt_default "http timeout seconds" "${TIMEOUT:-8}")" FORCE_SCHEME="$(prompt_default "force scheme: empty/http/https" "${FORCE_SCHEME:-}")" SHOW_ALL="$(prompt_default "include unlabeled containers (1/0)" "${SHOW_ALL:-0}")" REFRESH="$(prompt_default "monitor refresh seconds" "${REFRESH:-10}")" OUTPUT_FORMAT="$(prompt_default "output format: table/json" "${OUTPUT_FORMAT:-table}")" STRICT_TLS="$(prompt_default "strict tls verify (1/0)" "${STRICT_TLS:-1}")" USE_COLOR="$(prompt_default "color mode: auto/force/never" "${USE_COLOR:-auto}")" EXIT_MODE="$(prompt_default "exit mode" "${EXIT_MODE:-default}")" write_config_file printf '%ssaved config:%s %s\n' "$GREEN" "$RESET" "$CONFIG_FILE" >&2 } interactive_fill_missing() { [ -n "$LABEL_KEY" ] || LABEL_KEY="$(prompt_default "docker label key" "caddy")" } json_escape() { printf '%s' "${1:-}" | sed \ -e 's/\\/\\\\/g' \ -e 's/"/\\"/g' \ -e 's/ /\\t/g' \ -e 's/ /\\r/g' \ -e ':a;N;$!ba;s/\n/\\n/g' } strip_domain() { local domain domain="$(trim "${1:-}")" domain="${domain#http://}" domain="${domain#https://}" domain="${domain%%/*}" domain="${domain%%\?*}" domain="${domain%%\#*}" domain="${domain%%:*}" printf '%s' "$domain" } valid_domain() { local domain domain="$(strip_domain "${1:-}")" [ -n "$domain" ] || return 1 printf '%s' "$domain" | grep -Eq '^(\*\.)?[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' } split_domains() { local raw raw="${1:-}" raw="${raw//$'\r'/}" raw="${raw//$'\n'/,}" raw="${raw//;/,}" printf '%s\n' "$raw" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' } docker_state() { case "${1:-}" in running) printf 'running' ;; restarting) printf 'restarting' ;; exited|created|paused|dead) printf 'stopped' ;; *) printf '%s' "${1:-unknown}" ;; esac } extract_domains_from_value() { local value line value="${1:-}" if printf '%s' "$value" | grep -q 'Host('; then printf '%s' "$value" | grep -oE '`[^`]+`' | tr -d '`' | while IFS= read -r line; do [ -n "$line" ] && printf '%s\n' "$line" done return 0 fi split_domains "$value" } discover_label_key() { local candidate found for candidate in \ "${LABEL_KEY:-}" \ caddy \ CADDY \ VIRTUAL_HOST \ virtual.host \ domain \ domains \ traefik.domain \ nginxproxymanager.domain do [ -n "$candidate" ] || continue found="$( docker ps -a --format "{{.Label \"$candidate\"}}" 2>/dev/null | grep -E '([A-Za-z0-9.-]+\.[A-Za-z]{2,}|Host\()' | head -n 1 || true )" if [ -n "$found" ]; then printf '%s' "$candidate" return 0 fi done return 1 } check_url() { local url curl_args code rc url="$1" curl_args="--silent --show-error --location --output /dev/null --write-out %{http_code} --connect-timeout $TIMEOUT --max-time $TIMEOUT --user-agent $PROGRAM/$VERSION" if [ "$STRICT_TLS" = "0" ]; then curl_args="$curl_args --insecure" fi # shellcheck disable=SC2086 code="$(curl $curl_args "$url" 2>/dev/null)" rc=$? if [ "$rc" -ne 0 ] || [ -z "$code" ] || [ "$code" = "000" ]; then return 1 fi printf '%s' "$code" return 0 } http_check() { local domain code domain="$1" code="" case "$domain" in \*.*) printf 'skipped|wildcard|--' return 0 ;; esac if [ "$FORCE_SCHEME" = "http" ]; then if code="$(check_url "http://$domain")"; then [ "$code" = "200" ] && printf 'online|http 200|http' || printf 'error|http %s|http' "$code" else printf 'offline|no connection|http' fi return 0 fi if [ "$FORCE_SCHEME" = "https" ]; then if code="$(check_url "https://$domain")"; then [ "$code" = "200" ] && printf 'online|http 200|https' || printf 'error|http %s|https' "$code" else printf 'offline|no connection|https' fi return 0 fi if code="$(check_url "https://$domain")"; then [ "$code" = "200" ] && printf 'online|http 200|https' || printf 'error|http %s|https' "$code" return 0 fi if code="$(check_url "http://$domain")"; then [ "$code" = "200" ] && printf 'online|http 200|http' || printf 'error|http %s|http' "$code" return 0 fi printf 'offline|no connection|--' } print_solid_line() { local width char i width="${1:-80}" char="${2:-─}" i=0 while [ "$i" -lt "$width" ]; do printf '%s' "$char" i=$((i + 1)) done printf '\n' } collect_rows() { local rows_tmp label raw found name state uptime created_at domains domain local container_state result check_status check_detail check_scheme domain_found rows_tmp="$1" found=0 label="$LABEL_KEY" if [ -z "$label" ] && [ "$AUTO_DISCOVER" = "1" ]; then label="$(discover_label_key || true)" fi [ -n "$label" ] || label="caddy" raw="$( docker ps -a \ --format "{{.Names}}|{{.State}}|{{.RunningFor}}|{{.CreatedAt}}|{{.Label \"$label\"}}" \ 2>/dev/null || true )" if [ -z "$raw" ]; then warn "no docker containers found, or docker is not accessible" printf '%s' "$label" > "${rows_tmp}.label" return 0 fi printf '%s' "$label" > "${rows_tmp}.label" while IFS='|' read -r name state uptime created_at domains; do [ -n "${name:-}" ] || continue container_state="$(docker_state "$state")" domain_found=0 if [ -n "${domains:-}" ]; then while IFS= read -r domain; do domain="$(strip_domain "$domain")" valid_domain "$domain" || continue domain_found=1 found=1 if [ "$DOMAINS_ONLY" = "1" ]; then printf '%s\t%s\n' "$name" "$domain" >> "$rows_tmp" continue fi result="$(http_check "$domain")" IFS='|' read -r check_status check_detail check_scheme <> "$rows_tmp" done <> "$rows_tmp" fi done </dev/null | sort -u } render_json() { local rows_tmp label first rows_tmp="$1" label="$(cat "${rows_tmp}.label" 2>/dev/null || printf '%s' "${LABEL_KEY:-caddy}")" # printf '{\n' # printf ' "program": "%s",\n' "$(json_escape "$PROGRAM")" # printf ' "version": "%s",\n' "$(json_escape "$VERSION")" # printf ' "os_family": "%s",\n' "$(json_escape "$OS_FAMILY")" # printf ' "os_name": "%s",\n' "$(json_escape "$OS_NAME")" printf ' "label": "%s",\n' "$(json_escape "$label")" printf ' "containers": [\n' first=1 while IFS=$'\t' read -r name state uptime domain check_status check_detail check_scheme; do [ -n "${name:-}" ] || continue [ "$first" -eq 1 ] || printf ',\n' first=0 printf ' {"container":"%s","docker_state":"%s","running_for":"%s","domain":"%s","status":"%s","result":"%s","scheme":"%s"}' \ "$(json_escape "$name")" \ "$(json_escape "$state")" \ "$(json_escape "$uptime")" \ "$(json_escape "$domain")" \ "$(json_escape "$check_status")" \ "$(json_escape "$check_detail")" \ "$(json_escape "$check_scheme")" done < "$rows_tmp" printf '\n ]\n' printf '}\n' } render_table() { local rows_tmp label local container_width state_width uptime_width domain_width status_width detail_width scheme_width total_width local name state uptime domain check_status check_detail check_scheme local online error offline skipped total local domain_count matched_count container_count local state_color status_color rows_tmp="$1" label="$(cat "${rows_tmp}.label" 2>/dev/null || printf '%s' "${LABEL_KEY:-caddy}")" container_width=12 state_width=12 uptime_width=14 domain_width=24 status_width=8 detail_width=14 scheme_width=6 while IFS=$'\t' read -r name state uptime domain check_status check_detail check_scheme; do [ "${#name}" -gt "$container_width" ] && container_width="${#name}" [ "${#state}" -gt "$state_width" ] && state_width="${#state}" [ "${#uptime}" -gt "$uptime_width" ] && uptime_width="${#uptime}" [ "${#domain}" -gt "$domain_width" ] && domain_width="${#domain}" [ "${#check_status}" -gt "$status_width" ] && status_width="${#check_status}" [ "${#check_detail}" -gt "$detail_width" ] && detail_width="${#check_detail}" [ "${#check_scheme}" -gt "$scheme_width" ] && scheme_width="${#check_scheme}" done < "$rows_tmp" total_width=$((container_width + state_width + uptime_width + domain_width + status_width + detail_width + scheme_width + 18)) if [ "$NO_HEADER" = "0" ]; then printf '\n' print_solid_line "$total_width" printf "${BOLD}${BLUE}%-${container_width}s %-${state_width}s %-${uptime_width}s %-${domain_width}s %-${status_width}s %-${detail_width}s %-${scheme_width}s${RESET}\n" \ "container" "docker_state" "running_for" "domain" "status" "result" "scheme" print_solid_line "$total_width" fi online=0 error=0 offline=0 skipped=0 while IFS=$'\t' read -r name state uptime domain check_status check_detail check_scheme; do state_color="$GRAY" status_color="$RED" case "$state" in running) state_color="$GREEN" ;; restarting) state_color="$YELLOW" ;; stopped) state_color="$RED" ;; esac case "$check_status" in online) status_color="$GREEN" online=$((online + 1)) ;; error) status_color="$YELLOW" error=$((error + 1)) ;; offline) status_color="$RED" offline=$((offline + 1)) ;; skipped) status_color="$GRAY" skipped=$((skipped + 1)) ;; esac printf "%-${container_width}s %s%-${state_width}s${RESET} %-${uptime_width}s %-${domain_width}s %s%-${status_width}s${RESET} %-${detail_width}s %-${scheme_width}s\n" \ "$name" \ "$state_color" "$state" \ "$uptime" \ "$domain" \ "$status_color" "$check_status" \ "$check_detail" \ "$check_scheme" done < "$rows_tmp" total=$((online + error + offline + skipped)) domain_count="$(cut -f4 "$rows_tmp" 2>/dev/null | grep -v '^-$' | sort -u | wc -l | tr -d ' ')" matched_count="$(cut -f1 "$rows_tmp" 2>/dev/null | sort -u | wc -l | tr -d ' ')" container_count="$(docker ps -aq 2>/dev/null | wc -l | tr -d ' ')" if [ "$NO_HEADER" = "0" ]; then print_solid_line "$total_width" fi printf "${GREEN}${BOLD}online${RESET}: %d " "$online" printf "${YELLOW}${BOLD}error${RESET}: %d " "$error" printf "${RED}${BOLD}offline${RESET}: %d " "$offline" printf "${GRAY}${BOLD}skipped${RESET}: %d " "$skipped" printf "${BLUE}${BOLD}total${RESET}: %d\n" "$total" printf "${CYAN}domains${RESET}: %s " "$domain_count" printf "${CYAN}matched_containers${RESET}: %s " "$matched_count" printf "${CYAN}all_containers${RESET}: %s " "$container_count" printf "${CYAN}label${RESET}: %s " "$label" printf "${CYAN}os${RESET}: %s\n" "$OS_FAMILY" } determine_exit_code() { local rows_tmp status rc rows_tmp="$1" rc=0 while IFS=$'\t' read -r _ _ _ _ status _ _; do case "$status" in offline) rc=2 ;; error) [ "$rc" -lt 2 ] && rc=1 ;; esac done < "$rows_tmp" return "$rc" } run_check() { local rows_tmp="" rows_tmp="$(mktemp "${TMPDIR:-/tmp}/check-containers.XXXXXX")" || die "failed to create temp file" trap '[ -n "${rows_tmp:-}" ] && rm -f "${rows_tmp}" "${rows_tmp}.label"' RETURN INT TERM EXIT collect_rows "$rows_tmp" if [ "$DOMAINS_ONLY" = "1" ]; then render_domains_only "$rows_tmp" return 0 fi case "$OUTPUT_FORMAT" in json) render_json "$rows_tmp" ;; *) render_table "$rows_tmp" ;; esac determine_exit_code "$rows_tmp" return $? } parse_args() { if [ "${1:-}" = "monitor" ]; then MONITOR=1 shift fi while [ "$#" -gt 0 ]; do case "$1" in -c|--color) USE_COLOR="force" ;; -n|--no-color) USE_COLOR="never" ;; -d|--domains) DOMAINS_ONLY=1 ;; --label) shift [ "$#" -gt 0 ] || die "--label requires a value" LABEL_KEY="$1" AUTO_DISCOVER=0 ;; --no-discover) AUTO_DISCOVER=0 ;; --no-header) NO_HEADER=1 ;; --timeout) shift [ "$#" -gt 0 ] || die "--timeout requires whole seconds" printf '%s' "$1" | grep -Eq '^[0-9]+$' || die "--timeout requires whole seconds" TIMEOUT="$1" ;; --refresh) shift [ "$#" -gt 0 ] || die "--refresh requires whole seconds" printf '%s' "$1" | grep -Eq '^[0-9]+$' || die "--refresh requires whole seconds" REFRESH="$1" ;; --http) FORCE_SCHEME="http" ;; --https) FORCE_SCHEME="https" ;; --all) SHOW_ALL=1 ;; --json) OUTPUT_FORMAT="json" ;; --table) OUTPUT_FORMAT="table" ;; --strict-tls) STRICT_TLS=1 ;; --insecure) STRICT_TLS=0 ;; --config) shift [ "$#" -gt 0 ] || die "--config requires a file path" CONFIG_FILE="$1" ;; --edit-config) EDIT_CONFIG=1 ;; --interactive) INTERACTIVE=1 ;; --exit-default) EXIT_MODE="default" ;; -h|--help) usage exit 0 ;; *) die "unknown option: $1" ;; esac shift done } main() { parse_args "$@" detect_os load_config_file setup_colors need docker need curl need awk need grep need sed need tr need cut need sort need wc need mktemp need date if [ "$EDIT_CONFIG" = "1" ]; then interactive_edit_config exit 0 fi if [ "$INTERACTIVE" = "1" ]; then interactive_fill_missing fi if [ "$MONITOR" = "1" ]; then trap 'printf "\n"; exit 0' INT TERM while true; do if [ -t 1 ]; then printf '\033[H\033[2J' fi # printf "${BOLD}${CYAN}%s monitor${RESET} " "$PROGRAM" # printf "${DIM}%s | refresh: %ss | os: %s | ctrl+c to exit${RESET}\n" \ printf "$(date '+%Y-%m-%d %H:%M:%S')" \ "$REFRESH" \ "$OS_FAMILY" run_check || true sleep "$REFRESH" done else run_check exit $? fi } main "$@"