LibrePortal/scripts/restore/restore_domains.sh
librelad 3fbc997a2d First-run restore: actually restore the system config, and check the domains
The installer's restore path printed "Settings restored" and had never restored
a setting. backupRestoreSystemConfig only STAGES — right in general, since
overwriting a running control plane's config should not be automatic — but
nothing ever adopted the staged tree. The backup locations, domains and logins
landed in $restore_dir/system-config and stayed there.

So adoption is its own step now (`restore adopt`), allowed only on a machine
with nothing on it yet. backup/locations/ is adopted as a subtree, since the
index is part of the path and that directory is the whole point: it holds every
repository and its credentials, which is what makes "one password unlocks the
rest" true. Deliberately NOT adopted: the container account and its generated
password, port allocations, docker/rootless wiring, and storage/locations —
those describe the old machine, and a registry of drives this box does not have
would make every placement lookup resolve to a phantom.

The guard failed in the shape this project keeps hitting. It globbed the
containers directory, but the manager can traverse that without listing it, so
the glob returned a literal '*', the loop skipped it, and the function returned
"first run" on a machine with three apps. It adopted over a live install in
testing. It now asks the container user for the listing and fails closed: an
unreadable directory means "in use", never "empty".

Two config modes were inverted, found because a restore cannot restore from a
snapshot that was never taken:

  - storage location configs were 0640 and hold no secrets. The backup runs as
    the container user, could not read them, and restic wrote an INCOMPLETE
    snapshot and exited 3 — so EVERY system-config backup failed once a second
    storage location existed. Now 0644, with the test asserting they stay
    secret-free so that mode remains defensible.

  - backup location configs were 0644 and hold the repository password; nobody
    could read them. They cannot simply be tightened, because the backup has to
    read the credentials it uses — so the directory carries the restriction
    (config-secure, manager:container 0750) and the file stays readable to the
    two accounts that belong.

Fixing that surfaced a third: config-adopt clamped existing parent directories
to manager:manager 0750, closing configs/backup to the container user and
breaking the credential read the directory fix had just preserved.

restore domains reports which restored domains point here, and the installer
offers to drop the strays. Three verdicts, not two: setupCheckDomainPointsHere
falls back to hostname -I, and comparing a public A record to a private 10.x
address would condemn every correctly-pointed domain on a LAN-only box, which
is the deployment this product targets. Unverifiable is never offered for
deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 04:18:43 +01:00

190 lines
7.6 KiB
Bash

#!/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:
#
# <verdict>\t<domain>\t<domain_ip>\t<detail>
#
# 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
}