#!/bin/bash
# /usr/local/bin/dashcaddy-watchdog — self-healing guard for DashCaddy on DNS2.
#
# Heals four failure classes that have actually knocked DashCaddy down:
#   1. Container down/unhealthy/removed          -> docker start / bash start.sh
#   2. Rogue host node process stealing :3001    -> kill it, restart container
#   3. Caddyfile wiped by a foreign generator    -> restore known-good, restart caddy
#   4. Caddy down or not serving                 -> restart caddy
#
# Runs from dashcaddy-watchdog.service (systemd timer, every 30s) or manually.
# Alerts go to Telegram (Sami) on every corrective action; rate-limited per
# action class (default 15 min) to avoid spam during flapping. All state in
# /var/lib/dashcaddy-watchdog/ so restarts of the watchdog never flap.
#
# Exit codes: 0 = healthy or healed cleanly. Healing never exits non-zero —
# a permanently-failed unit would pollute systemctl --failed monitoring.

set -u

CONTAINER="dashcaddy-api"
START_SH="/opt/dashcaddy/start.sh"
CADDYFILE="/etc/caddy/Caddyfile"
KNOWN_GOOD="/var/lib/dashcaddy-watchdog/known-good-Caddyfile"
STATE_DIR="/var/lib/dashcaddy-watchdog"
LOG="/var/log/dashcaddy-watchdog.log"
ALERT_COOLDOWN=$((15 * 60))   # seconds between alerts of the same class
LOG_MAX=1048576               # 1 MiB
# Caddyfile integrity gates (adversarial review: markers alone can be
# satisfied by a damaged file). Size floor excludes the 4952-byte foreign-
# generator file from the 2026-08-13 incident; service-block floor excludes
# marker-duplication damage.
CADDYFILE_MIN_BYTES=10000
CADDYFILE_MIN_SITES=10

mkdir -p "$STATE_DIR"
touch "$LOG"

log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG"; }

# --- Telegram alerting (best-effort; a notify failure never blocks healing) ---
notify() {  # notify <class> <message>
  local class="$1" msg="$2"
  local now last elapsed
  now=$(date +%s)
  local stamp="$STATE_DIR/alert-$class"
  if [ -f "$stamp" ]; then
    last=$(cat "$stamp" 2>/dev/null | tr -cd '0-9')
    last="${last:-0}"
    elapsed=$(( now - last ))
    if [ "$elapsed" -lt "$ALERT_COOLDOWN" ]; then
      log "ALERT-SUPPRESSED class=$class (${elapsed}s < ${ALERT_COOLDOWN}s)"
      return 0
    fi
  fi
  local token chat sent
  token=$(grep -E '^HERMES_ENV_TELEGRAM_BOT_TOKEN=' /root/.hermes/.env | head -1 | cut -d= -f2-)
  chat="637130179"
  if [ -z "$token" ]; then
    log "ALERT-NO-TOKEN class=$class msg=$msg"
    return 0
  fi
  sent=$(curl -s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage" \
    -d chat_id="$chat" -d text="🛡️ DashCaddy watchdog (DNS2): $msg" \
    2>/dev/null | grep -c '"ok":true')
  if [ "${sent:-0}" -ge 1 ]; then
    date +%s > "$stamp"
    log "ALERT-SENT class=$class msg=$msg"
  else
    log "ALERT-SEND-FAILED class=$class (cooldown NOT burned)"
  fi
}

log_rotate() {
  [ -f "$LOG" ] || return 0
  local size
  size=$(stat -c%s "$LOG" 2>/dev/null || echo 0)
  if [ "$size" -gt "$LOG_MAX" ]; then
    tail -c $((LOG_MAX / 2)) "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
  fi
}

# --- Health probes -----------------------------------------------------------
container_running() {
  docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER}$"
}

container_healthy() {
  local st started age
  st=$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null) || return 1
  if [ "$st" = "healthy" ]; then return 0; fi
  if [ "$st" = "starting" ]; then
    # Max-starting-age guard (adversarial review): never park forever on a
    # container stuck in 'starting'. StartPeriod is 10s; allow 90s slack.
    started=$(docker inspect -f '{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null)
    age=$(( $(date +%s) - $(date -d "${started:-1970-01-01}" +%s 2>/dev/null || echo 0) ))
    [ "$age" -le 90 ]
    return
  fi
  return 1
}

rogue_on_port() {
  # any listener on 127.0.0.1:3001 that is NOT docker-proxy
  ss -H -tlnp 'sport = :3001' 2>/dev/null | grep -v docker-proxy | grep -q .
}

caddy_up() {
  systemctl is-active --quiet caddy
}

caddy_serving() {
  # status.sami via loopback; internal CA cert -> -k
  curl -sk -m 8 -o /dev/null -w '%{http_code}' --resolve status.sami:443:127.0.0.1 https://status.sami/ 2>/dev/null | grep -qE '^[23]'
}

caddyfile_intact() {
  # Integrity gates (adversarial review hardened): markers + size floor +
  # service-block floor + caddy syntax validation.
  [ -f "$CADDYFILE" ] || return 1
  local refs ca bytes sites
  refs=$(grep -c 'dashcaddy_auth' "$CADDYFILE" 2>/dev/null || echo 0)
  ca=$(grep -c 'sami-ca' "$CADDYFILE" 2>/dev/null || echo 0)
  bytes=$(stat -c%s "$CADDYFILE" 2>/dev/null || echo 0)
  sites=$(grep -cE '^[a-z0-9.-]+\.(sami|net|com|me|org)[^a-z0-9-]*\{$' "$CADDYFILE" 2>/dev/null || echo 0)
  [ "$refs" -ge 8 ] && [ "$ca" -ge 1 ] && [ "$bytes" -ge "$CADDYFILE_MIN_BYTES" ] && [ "$sites" -ge "$CADDYFILE_MIN_SITES" ]
}

refresh_known_good() {
  # Refuse to refresh within 10 minutes of a caddyfile heal (prevents the
  # partial-write / post-heal window from poisoning the snapshot).
  local heal_stamp="$STATE_DIR/last-caddyfile-heal"
  if [ -f "$heal_stamp" ]; then
    local since=$(( $(date +%s) - $(cat "$heal_stamp" | tr -cd '0-9' || echo 0) ))
    if [ "$since" -lt 600 ]; then
      return 0
    fi
  fi
  if caddyfile_intact; then
    if [ -f "$KNOWN_GOOD" ] && cmp -s "$CADDYFILE" "$KNOWN_GOOD"; then
      return 0  # unchanged — keep existing snapshot (preserves mtime)
    fi
    cp -a "$CADDYFILE" "$KNOWN_GOOD"
    log "KNOWN-GOOD refreshed ($(stat -c%s "$KNOWN_GOOD" 2>/dev/null || echo '?') bytes)"
  fi
}

# --- Remediation --------------------------------------------------------------
heal_container_start() {
  log "HEAL container-start"
  docker start "$CONTAINER" >/dev/null 2>&1
  if ! container_running; then
    log "HEAL container-start failed; falling back to start.sh"
    bash "$START_SH" >> "$LOG" 2>&1
  fi
  notify container "container was down — restarted it"
}

heal_container_recreate() {
  log "HEAL container-recreate (start.sh)"
  bash "$START_SH" >> "$LOG" 2>&1
  notify container "container unhealthy — recreated via start.sh"
}

heal_rogue_port() {
  local pids
  pids=$(ss -H -tlnp 'sport = :3001' 2>/dev/null | grep -v docker-proxy | grep -oP 'pid=\K[0-9]+' | sort -u)
  log "HEAL rogue-port pids=$pids"
  for pid in $pids; do
    [ "$pid" = "$$" ] && continue
    local cmdline
    cmdline=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ')
    case "$cmdline" in
      *docker-proxy*|*dashcaddy-watchdog*) continue ;;
    esac
    # PID recycling guard: the process must still be listening on :3001
    if ! ss -H -tlnp 'sport = :3001' 2>/dev/null | grep -q "pid=$pid"; then
      continue
    fi
    log "KILL pid=$pid cmd=$cmdline"
    kill "$pid" 2>/dev/null || true
  done
  sleep 3
  ss -H -tlnp 'sport = :3001' | grep -q docker-proxy || docker restart "$CONTAINER" >/dev/null 2>&1
  notify rogue-port "rogue host process was holding port 3001 — killed it, container restarted"
}

heal_caddyfile() {
  log "HEAL caddyfile-restore"
  if [ -f "$KNOWN_GOOD" ]; then
    cp -a "$KNOWN_GOOD" "$CADDYFILE"
    chown caddy:caddy "$CADDYFILE" 2>/dev/null || true
    systemctl restart caddy
    notify caddyfile "Caddyfile was wiped/overwritten by a foreign generator — restored known-good and restarted Caddy"
  else
    notify caddyfile "Caddyfile damaged and no known-good snapshot exists — MANUAL ACTION NEEDED"
  fi
  # Post-heal grace stamp: caddy_serving() skipped for 60s after this point.
  date +%s > "$STATE_DIR/last-caddyfile-heal"
}

heal_caddy_down() {
  log "HEAL caddy-restart"
  systemctl restart caddy
  notify caddy "caddy was down — restarted it"
}

heal_caddy_5xx() {
  log "HEAL caddy-5xx"
  systemctl restart caddy
  notify caddy "caddy was not serving status.sami (non-2xx/3xx) — restarted it"
}

# --- Main ----------------------------------------------------------------------
log_rotate
ACTION_TAKEN=0

# 0. Maintain known-good Caddyfile snapshot whenever current file is intact.
refresh_known_good

# 3. Caddyfile integrity (before caddy health so restore happens first).
if ! caddyfile_intact && [ -f "$KNOWN_GOOD" ]; then
  heal_caddyfile
  ACTION_TAKEN=1
fi

# 4. Caddy up + serving
if ! caddy_up; then
  heal_caddy_down
  ACTION_TAKEN=1
fi
if ! caddy_serving; then
  # Skip if we JUST restarted caddy this cycle (post-heal grace).
  if [ -f "$STATE_DIR/last-caddyfile-heal" ]; then
    local_grace=$(( $(date +%s) - $(cat "$STATE_DIR/last-caddyfile-heal" | tr -cd '0-9' || echo 0) ))
    if [ "$local_grace" -lt 60 ]; then
      log "caddy_serving skipped (post-caddyfile-heal grace ${local_grace}s)"
    else
      heal_caddy_5xx
      ACTION_TAKEN=1
    fi
  else
    heal_caddy_5xx
    ACTION_TAKEN=1
  fi
fi

# 2. Rogue host process on :3001
if rogue_on_port; then
  heal_rogue_port
  ACTION_TAKEN=1
fi

# 1. Container down/unhealthy (last: start.sh recreates and re-binds 3001)
if ! container_running; then
  heal_container_start
  ACTION_TAKEN=1
elif ! container_healthy; then
  heal_container_recreate
  ACTION_TAKEN=1
fi

if [ "$ACTION_TAKEN" = "1" ]; then
  log "cycle complete: corrective action taken"
else
  log "cycle complete: healthy"
fi
exit 0
