#!/bin/zsh # # Publish public DNS records for the Caddy site blocks that are explicitly # marked public, and refresh the A record for the current WAN IP. # # A site block is published ONLY if the line immediately above it is exactly # # # ddns: public # # Anything unmarked stays off public DNS. That default matters: this script # used to scrape *every* site block, which is how internal-only services ended # up with public records (and, via Certificate Transparency, public hostnames). # Forgetting a marker now fails closed. CADDYFILE=/home/connor/Caddyfile NTFY_ENV=/home/connor/.config/ntfy/publish.env NTFY_URL=https://ntfy.rcjohnstone.com/infra notify() { # Best-effort: never let a notification failure fail the run. [[ -r $NTFY_ENV ]] || return 0 local user pass user=$(sed -n 's/^NTFY_USER=//p' $NTFY_ENV) pass=$(sed -n 's/^NTFY_PASS=//p' $NTFY_ENV) [[ -n $user && -n $pass ]] || return 0 curl -sS -m 15 -u "$user:$pass" \ -H "Title: $1" -H "Priority: ${3:-default}" -H "Tags: ${4:-warning}" \ -d "$2" $NTFY_URL >/dev/null 2>&1 return 0 } fail() { print -u2 "ddns_update: $1" notify "DDNS update failed" "$1" high rotating_light exit 1 } ip=$(curl -sq4 -m 20 ifconfig.me) if [[ -z "$ip" ]]; then fail "could not determine public IPv4, aborting" fi if ! out=$(ddns -p $ip 4 2>&1); then fail "ddns -p failed:\n$out" fi # Emit the hostname from each site block preceded by the marker. Blank lines # between the marker and the block are tolerated; anything else resets it. subdomains=$(awk ' /^[[:space:]]*#[[:space:]]*ddns:[[:space:]]*public[[:space:]]*$/ { pub=1; next } /^[[:space:]]*$/ { next } /^[^[:space:]#].*\{[[:space:]]*$/ { if (pub) { name=$0; sub(/[{,].*/, "", name); gsub(/[[:space:]]/, "", name); print name } pub=0; next } { pub=0 } ' $CADDYFILE \ | grep -E '(^|\.)rcjohnstone\.com$' \ | sed 's/^rcjohnstone\.com$/@.rcjohnstone.com/' \ | sort -u) if [[ -z "$subdomains" ]]; then fail "no '# ddns: public' markers found in $CADDYFILE -- refusing to continue" fi added=() for subdomain in ${(f)subdomains} do # already-tracked names just print "already exists"; that is not an error if out=$(ddns -s $subdomain 2>&1); then print -r -- "$out" | grep -v 'already exists' print -r -- "$out" | grep -q 'already exists' || added+=$subdomain else print -u2 "ddns_update: failed to add $subdomain:\n$out" fi done if (( ${#added} )); then notify "DDNS: new public records" \ "Now publicly resolvable:\n${(F)added}" default globe_with_meridians fi # Reconcile. The bare `ddns` below refreshes every subdomain in ddns.db, not # just the ones we asked for -- that is how `spanish` acquired a public record # despite having no marker. Anything tracked locally but no longer marked is # drift, and drift here means a service is on the internet that we did not # intend to publish. Report it loudly rather than silently republishing. tracked=$(python3 - <<'EOF' 2>/dev/null import sqlite3 try: c = sqlite3.connect('/home/connor/.config/ddns/ddns.db') print("\n".join(sorted(r[0] for r in c.execute("select name from subdomains")))) except Exception: pass EOF ) if [[ -n "$tracked" ]]; then marked=$(print -r -- "$subdomains" | sed 's/\.rcjohnstone\.com$//' | sed 's/^@$/@/' | sort -u) drift=$(comm -23 <(print -r -- "$tracked") <(print -r -- "$marked")) if [[ -n "$drift" ]]; then print -u2 "ddns_update: tracked but NOT marked public in the Caddyfile:\n$drift" notify "DDNS drift: unmarked names still published" \ "These have DNS records but no '# ddns: public' marker:\n$drift\n\nRemove them from DigitalOcean and from ddns.db, or add the marker." \ high warning fi fi ddns