T-HUB/infra/occupymedia/edge/edge-watcher.sh
glass beadian 9365b561b2 Add edge stack: second ingress path + dual-path DNS failover
Cloudflare's tunnel edge-pool assignment can blackhole the site for whole
regions (2026-10-10: tunnel hostnames served from an unreachable anycast
range for hours). This adds an independent path and the automation that
keeps occupymedia.cc reachable when either path is sick:

- nginx edge mirroring the tunnel ingress (app/api/tube/chat/ping), LE
  wildcard via dns-01, twice-daily renewal
- DOCKER-USER firewall: 80/443 restricted to Cloudflare ranges (CF mode)
  or OPEN for grey-cloud mode via firewall.mode
- edge-watcher (60s systemd timer): dynamic public-IP sync, dual-path
  health checks, DNS failover TUNNEL<->ORIGIN/GREY with hysteresis,
  gated by EDGE_ENABLED
- ping.occupymedia.cc sacrificial probe host for activation
- deployment runbook in README.md; credential files shipped as .example
2026-10-10 21:22:15 +00:00

120 lines
5.1 KiB
Bash
Executable file

#!/bin/bash
# edge-watcher.sh — keeps occupymedia.cc reachable regardless of which path is sick.
#
# Two ingress paths behind Cloudflare's proxy:
# TUNNEL proxied CNAMEs -> <tunnel>.cfargotunnel.com (edge pool can go bad per-region)
# ORIGIN proxied A records -> current public IP -> nginx edge on CT 150 (443 CF-only)
#
# Every run:
# 1. sync ORIGIN A records to the current public IP (the IP is dynamic)
# 2. health-check both paths (tunnel = what visitors see via default DNS)
# 3. fail over with hysteresis, log every decision
#
# Gated by EDGE_ENABLED=true in watcher.env — no DNS writes until the port
# forward is verified. State: edge.state. Log: edge-watcher.log (rotated by size).
set -uo pipefail
cd "$(dirname "$0")"
# shellcheck disable=SC1091
source ./watcher.env
ZONE="4bd0ac2e13a735d2e0068ce3feea2996"
TUNNEL_NAME="e5695d8b-94eb-4e2b-9600-ed3cbf20e541.cfargotunnel.com"
HOSTS=(app api tube chat)
STATE_FILE="./edge.state"
LOG="./edge-watcher.log"
MAXLOG=1048576
log() { echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" >> "$LOG"; }
# keep the log bounded
if [[ -f $LOG && $(stat -c%s "$LOG") -gt $MAXLOG ]]; then
tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
api() { curl -s -m 15 -H "Authorization: Bearer $CF_API_TOKEN" "$@"; }
record_id() { # host type -> id or empty
api "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=$2&name=$1.occupymedia.cc" \
| python3 -c "import json,sys; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null
}
public_ip() { curl -s -m 10 ifconfig.me || curl -s -m 10 api.ipify.org; }
set_mode() { # TUNNEL|ORIGIN|GREY
local mode=$1 ip=$2
local proxied=true
[[ $mode == GREY ]] && proxied=false
for h in "${HOSTS[@]}"; do
if [[ $mode == ORIGIN || $mode == GREY ]]; then
local cid; cid=$(record_id "$h" CNAME)
[[ -n $cid ]] && api -X DELETE "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$cid" > /dev/null
local aid; aid=$(record_id "$h" A)
if [[ -n $aid ]]; then
api -X PATCH -H "Content-Type: application/json" \
-d "{\"content\":\"$ip\",\"proxied\":$proxied}" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$aid" > /dev/null
else
api -X POST -H "Content-Type: application/json" \
-d "{\"type\":\"A\",\"name\":\"$h.occupymedia.cc\",\"content\":\"$ip\",\"proxied\":$proxied}" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" > /dev/null
fi
else
local aid; aid=$(record_id "$h" A)
[[ -n $aid ]] && api -X DELETE "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$aid" > /dev/null
local cid; cid=$(record_id "$h" CNAME)
if [[ -z $cid ]]; then
api -X POST -H "Content-Type: application/json" \
-d "{\"type\":\"CNAME\",\"name\":\"$h.occupymedia.cc\",\"content\":\"$TUNNEL_NAME\",\"proxied\":true}" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" > /dev/null
fi
fi
done
echo "mode=$mode failures=0 tunnel_ok=0 origin_ok=0 ip=$ip" > "$STATE_FILE"
}
# ---- state ----
if [[ -f $STATE_FILE ]]; then source "$STATE_FILE"; else mode=TUNNEL; fi
: "${mode:=TUNNEL}"; : "${failures:=0}"; : "${tunnel_ok:=0}"; : "${origin_ok:=0}"; : "${ip:=}"
: "${TARGET_MODE:=ORIGIN}"
now_ip=$(public_ip)
[[ $now_ip =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || { log "ERROR public ip lookup failed ('$now_ip')"; exit 0; }
# ---- health checks ----
tunnel_code=$(curl -s -o /dev/null -w '%{http_code}' -m 12 "https://api.occupymedia.cc/healthz" 2>/dev/null)
[[ $tunnel_code == 200 ]] && tunnel_healthy=1 || tunnel_healthy=0
origin_code=$(curl -s -o /dev/null -w '%{http_code}' -m 12 --resolve api.occupymedia.cc:443:127.0.0.1 \
--cacert ./certs/live/occupymedia.cc/chain.pem 2>/dev/null "https://api.occupymedia.cc/healthz")
[[ $origin_code == 200 ]] && origin_healthy=1 || origin_healthy=0
# ---- IP drift (only meaningful in ORIGIN mode; harmless otherwise) ----
if [[ $mode == ORIGIN && -n ${EDGE_ENABLED:-} && $ip != "$now_ip" ]]; then
log "public ip changed $ip -> $now_ip; updating A records"
set_mode ORIGIN "$now_ip"; mode=ORIGIN
fi
# ---- decisions (gated) ----
if [[ ${EDGE_ENABLED:-} != true ]]; then
echo "mode=$mode failures=$failures tunnel_ok=$tunnel_ok origin_ok=$origin_ok ip=$now_ip" > "$STATE_FILE"
log "EDGE_ENABLED!=true — observation only (tunnel=$tunnel_code origin=$origin_code)"
exit 0
fi
if [[ $mode == TUNNEL ]]; then
if [[ $tunnel_healthy == 1 ]]; then failures=0; else failures=$((failures+1)); fi
if [[ $failures -ge 2 && $origin_healthy == 1 ]]; then
log "tunnel failed $failuresx, origin healthy — FAILING OVER to $TARGET_MODE ($now_ip)"
set_mode "$TARGET_MODE" "$now_ip"
else
echo "mode=TUNNEL failures=$failures tunnel_ok=$tunnel_ok origin_ok=$origin_ok ip=$now_ip" > "$STATE_FILE"
fi
else
if [[ $origin_healthy == 1 ]]; then failures=0; else failures=$((failures+1)); fi
if [[ $failures -ge 3 ]]; then
log "origin path failing ($failuresx) — FAILING BACK to TUNNEL"
set_mode TUNNEL "$now_ip"
else
echo "mode=$mode failures=$failures tunnel_ok=$tunnel_ok origin_ok=$origin_ok ip=$now_ip" > "$STATE_FILE"
fi
fi