#!/usr/bin/env bash # Shared helpers for ops/backup.sh, ops/backup-forgejo.sh, and # tradein-mvp/deploy/backup-tradein-db.sh (#2203, missed-run detection). # # SOURCED, not executed directly — no shebang execution of its own. Inherits # the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins + # coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by # notify() when Telegram vars are actually set — curl is already a hard # requirement of ops/uptime-healthcheck.sh on the same box, so this adds no # new dependency). # # Load with (script computes its own dir so this works regardless of cron's # CWD or which repo subdir the caller lives in): # SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source "$SCRIPT_DIR/lib-backup.sh" # caller is in ops/ # source "$SCRIPT_DIR/../../ops/lib-backup.sh" # caller is in tradein-mvp/deploy/ log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } # --- notify ------------------------------------------------------------ # Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) — # this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN / # TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file # (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so # backup alerting doesn't depend on the uptime watchdog's config being # present, and vice versa. Point both files at the same bot/chat if you want # one Telegram destination for everything — that's an ops choice, not this # script's concern. # # No-op (logs only) when unset — this is the extension point: to wire a # different channel later, edit ONLY this function; every caller in this repo # goes through notify(), never curl/telegram directly. notify() { local text="$1" local backup_env="${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}" if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then # shellcheck source=/dev/null [[ -f "$backup_env" ]] && source "$backup_env" fi if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then log "NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in ${backup_env}): $text" notify_fallback_mail "$text" return 0 fi curl -fsS --max-time 15 \ -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ -d "disable_web_page_preview=true" \ --data-urlencode "text=${text}" \ >/dev/null 2>&1 \ || { log "WARN: telegram sendMessage failed — пробую запасной канал"; \n notify_fallback_mail "$text"; } } # --- запасной канал оповещения (#3059) ---------------------------------- # Зачем: раньше единственным каналом был Telegram, и `|| log WARN` означало, # что при его недоступности алерт просто ТЕРЯЛСЯ — оставалась строка в логе, # который никто не читает, пока не случится беда. Самоскрывающаяся поломка: # канал, которым мы узнаём о проблемах, сам и есть проблема. # # Это не паранойя, а замер (#3059, 2026-08-23): с нового хоста Poincare из семи # публикуемых адресов api.telegram.org отвечает РОВНО ОДИН (149.154.167.220), # и скан всей подсети 149.154.167.0/24 не нашёл больше ни одного. Через прокси # Telegram не проходит вовсе — узлы российские. После переезда весь # Telegram-канал висит на одном адресе, без запасного. # # Почему почта: с Poincare проверено — smtp.beget.com:465 OPEN (587 закрыт). # Транспорт — тот же curl, который уже является жёсткой зависимостью файла; # новых пакетов не требуется. # # Настраивается тем же env-файлом, что и Telegram. Не задано — ведёт себя как # раньше. Кредов в репозитории нет: # ALERT_SMTP_URL=smtps://smtp.beget.com:465 # ALERT_SMTP_USER= / ALERT_SMTP_PASS= / ALERT_MAIL_FROM= / ALERT_MAIL_TO= notify_fallback_mail() { local text="$1" if [[ -z "${ALERT_SMTP_URL:-}" || -z "${ALERT_SMTP_USER:-}" \ || -z "${ALERT_SMTP_PASS:-}" || -z "${ALERT_MAIL_FROM:-}" \ || -z "${ALERT_MAIL_TO:-}" ]]; then # ГРОМКО: это последний рубеж, тишина здесь = потерянный алерт. log "АЛЕРТ НЕ ДОСТАВЛЕН (telegram недоступен, почта не настроена): $text" return 1 fi local subject body rc=0 subject=$(printf '%s' "$text" | head -c 120 | tr '\n' ' ') body=$(printf 'From: %s\nTo: %s\nSubject: [gendesign] %s\n\n%s\n' \ "$ALERT_MAIL_FROM" "$ALERT_MAIL_TO" "$subject" "$text") # Пароль идёт через --user, поэтому stderr curl целиком не печатаем — в нём # может оказаться строка подключения. Логируем только код возврата. printf '%s' "$body" | curl -fsS --max-time 30 --ssl-reqd \ --url "$ALERT_SMTP_URL" \ --user "${ALERT_SMTP_USER}:${ALERT_SMTP_PASS}" \ --mail-from "$ALERT_MAIL_FROM" \ --mail-rcpt "$ALERT_MAIL_TO" \ --upload-file - >/dev/null 2>&1 || rc=$? if (( rc == 0 )); then log "Алерт доставлен запасным каналом (почта) вместо Telegram" return 0 fi log "АЛЕРТ НЕ ДОСТАВЛЕН НИ ОДНИМ КАНАЛОМ (telegram упал, curl smtp rc=${rc}): $text" return 1 } # --- sentinel (missed-run detection) ------------------------------------ # write_sentinel — call ONLY after every guard in the caller has # already passed (mirrors the existing "never mark success before a dump is # verified good" discipline in backup.sh/backup-tradein-db.sh). Content is # just an ISO-8601 UTC timestamp for human debugging; the actual staleness # check below is based on the file's mtime, not on parsing that content. write_sentinel() { local sentinel_file="$1" mkdir -p "$(dirname "$sentinel_file")" date -u +'%Y-%m-%dT%H:%M:%SZ' > "$sentinel_file" } # sentinel_age_hours — echoes the sentinel's age in whole hours on # stdout. Returns 1 (nothing echoed) if the file doesn't exist or its mtime # can't be read — caller treats that as "stale" (a backup that never # succeeded is exactly the case this exists to catch). sentinel_age_hours() { local sentinel_file="$1" now_epoch sentinel_epoch [[ -f "$sentinel_file" ]] || return 1 now_epoch=$(date -u +%s) # GNU stat (prod VM, Debian) first; BSD stat fallback (local/macOS dev). sentinel_epoch=$(stat -c %Y "$sentinel_file" 2>/dev/null || stat -f %m "$sentinel_file" 2>/dev/null) || return 1 echo $(( (now_epoch - sentinel_epoch) / 3600 )) } # --- transition-tracked alert state -------------------------------------- # Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a # flat "