#!/bin/bash # Domain reconciliation for a restore. # # A backup carries the domains the OLD machine served. After adoption they are # this machine's domains — but DNS still points at a server that may not exist # any more. Nothing in the restore path checked this, so the first sign of # trouble was Traefik failing to get a certificate long after the installer had # said it was finished. # # This is a report, never a refusal. A domain that does not resolve here is a # perfectly normal state five minutes into a rebuild: the user is about to go # and repoint it. What is not acceptable is not being told. # # Emits one record per domain on stdout: # # \t\t\t # # verdict is ok | elsewhere | unresolved. # Every CFG_DOMAIN_N actually set, in slot order. restoreDomainList() { local f="${configs_dir%/}/network/network_domains" [[ -r "$f" ]] || return 0 local line v while IFS= read -r line; do [[ "$line" =~ ^CFG_DOMAIN_[0-9]+= ]] || continue v="${line#*=}" # The config files carry a trailing comment column; strip it and the # whitespace that pads it, or every domain arrives with an essay # attached and no DNS lookup ever matches. v="${v%%#*}" v="${v#"${v%%[![:space:]]*}"}" v="${v%"${v##*[![:space:]]}"}" # updateConfigOption writes an empty value as a literal "" — without # stripping the quotes every cleared slot reads back as a two-character # domain, and a config with nine empty slots reports nine failures. v="${v%\"}"; v="${v#\"}" v="${v%\'}"; v="${v#\'}" v="${v#"${v%%[![:space:]]*}"}" v="${v%"${v##*[![:space:]]}"}" [[ -n "$v" ]] && printf '%s\n' "$v" done < "$f" } # This server's public address, or empty when it cannot be established. # # Kept apart from the LAN address on purpose. setupCheckDomainPointsHere falls # back to `hostname -I` when the public lookup fails, which is fine for its own # purposes but wrong here: comparing a public A record against a private # 10.x address makes every correctly-pointed domain look misconfigured, and # LibrePortal is explicitly a LAN/VPN-first product where that lookup failing # is ordinary rather than exceptional. restoreServerPublicIp() { local ip ip=$(dig +short +time=3 +tries=1 myip.opendns.com @resolver1.opendns.com 2>/dev/null | head -1) [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 printf '%s' "$ip" } _restoreIsPrivateIp() { case "${1:-}" in 10.*|127.*|192.168.*|169.254.*) return 0 ;; 172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;; *) return 1 ;; esac } # Check one domain against a known server address. # # The server address is passed in rather than resolved here: it is the same for # every domain, and looking it up per domain meant a network round trip each # time for an answer that could not have changed. restoreDomainCheck() { local domain="$1" server_ip="$2" local ip ip=$(dig +short +time=3 +tries=1 "$domain" A 2>/dev/null | grep -E '^[0-9.]+$' | head -1) if [[ -z "$ip" ]]; then # A literal '-' rather than an empty field: tab is IFS whitespace, so # `IFS=$'\t' read` collapses two adjacent tabs into one and every # column after the gap shifts left — which silently emptied the detail. printf 'unresolved\t%s\t-\t%s\n' "$domain" "no DNS record found" elif [[ -z "$server_ip" ]]; then printf 'unknown\t%s\t%s\t%s\n' "$domain" "$ip" "resolves to $ip — could not check this server's public address" elif [[ "$ip" == "$server_ip" ]]; then printf 'ok\t%s\t%s\t%s\n' "$domain" "$ip" "points at this server" else printf 'elsewhere\t%s\t%s\t%s\n' "$domain" "$ip" "points at $ip, not this server" fi } # Report on every restored domain. Leaves the ones that resolve here in # RESTORE_DOMAINS_OK and the rest in RESTORE_DOMAINS_ELSEWHERE, so a caller can # offer to leave the strays out of the Traefik config rather than shipping a # router for a name that cannot reach this box. restoreDomainReport() { RESTORE_DOMAINS_OK=() RESTORE_DOMAINS_ELSEWHERE=() RESTORE_DOMAINS_UNKNOWN=() local -a domains=() local d while IFS= read -r d; do [[ -n "$d" ]] && domains+=("$d"); done < <(restoreDomainList) if (( ${#domains[@]} == 0 )); then isNotice "No domains in the restored config — nothing to check." return 0 fi # Resolved once rather than per domain: it cannot change between them. local server_ip="" server_ip=$(restoreServerPublicIp) || server_ip="" local lan_ip; lan_ip=$(hostname -I 2>/dev/null | awk '{print $1}') if [[ -n "$server_ip" ]]; then isNotice "Checking ${#domains[@]} restored domain(s) against this server ($server_ip)…" else isNotice "Checking ${#domains[@]} restored domain(s)…" fi echo "" local rec verdict domain ip detail for domain in "${domains[@]}"; do rec=$(restoreDomainCheck "$domain" "$server_ip") IFS=$'\t' read -r verdict domain ip detail <<< "$rec" case "$verdict" in ok) printf ' \033[0;32m✓\033[0m %-28s %s\n' "$domain" "$detail" RESTORE_DOMAINS_OK+=("$domain") ;; elsewhere) printf ' \033[0;33m!\033[0m %-28s %s\n' "$domain" "$detail" RESTORE_DOMAINS_ELSEWHERE+=("$domain") ;; unknown) # Unverifiable is not the same as wrong, and offering to delete # a domain on the strength of a failed lookup would be. printf ' \033[0;33m?\033[0m %-28s %s\n' "$domain" "$detail" RESTORE_DOMAINS_UNKNOWN+=("$domain") ;; *) printf ' \033[0;33m?\033[0m %-28s %s\n' "$domain" "$detail" RESTORE_DOMAINS_ELSEWHERE+=("$domain") ;; esac done echo "" if (( ${#RESTORE_DOMAINS_UNKNOWN[@]} > 0 )); then isNotice "Could not work out this server's public address, so ${#RESTORE_DOMAINS_UNKNOWN[@]} domain(s) could not be checked." isNotice "That is expected on a LAN-only or VPN-only box${lan_ip:+ (this one answers on $lan_ip)}." fi if (( ${#RESTORE_DOMAINS_ELSEWHERE[@]} > 0 )); then isNotice "${#RESTORE_DOMAINS_ELSEWHERE[@]} of ${#domains[@]} do not point here yet." isNotice "That is normal mid-rebuild — update the DNS A records to this server and they will work." isNotice "Until then Traefik cannot get a certificate for them, so those sites stay unreachable." else isSuccessful "Every restored domain already points at this server." fi return 0 } # Drop the domains that do not point here out of the live config, keeping their # slot order for the ones that stay. # # Offered rather than done: a user who is about to repoint DNS wants them kept, # and a user rebuilding onto a box that will never own those names wants them # gone. Guessing either way is worse than asking. restoreDomainsDropElsewhere() { local f="${configs_dir%/}/network/network_domains" [[ -r "$f" ]] || return 1 (( ${#RESTORE_DOMAINS_ELSEWHERE[@]} > 0 )) || return 0 local d slot=1 for d in "${RESTORE_DOMAINS_OK[@]}"; do updateConfigOption "CFG_DOMAIN_$slot" "$d" >/dev/null 2>&1 slot=$((slot + 1)) done while (( slot <= 9 )); do updateConfigOption "CFG_DOMAIN_$slot" "" >/dev/null 2>&1 slot=$((slot + 1)) done isSuccessful "Kept ${#RESTORE_DOMAINS_OK[@]} domain(s); removed ${#RESTORE_DOMAINS_ELSEWHERE[@]} that do not point here." isNotice "Add them back on the Domains page once DNS is updated." return 0 }