#!/usr/bin/env bash
# shellcheck disable=SC1090,SC2154,SC2024  # variables come from sourced host.conf / env files
# hostrepo-restore --from <repository dir> [--only STEP] [--dry-run] [--yes] [--isolated]
#
# Rebuild a host from a repository made by hostrepo-backup, on a fresh install
# of the same OS. Steps, in order (each can be run alone with --only):
#
#   users      recreate accounts and groups with their original uid/gid, before
#              any package can create them with different ones
#   packages   apt sources and keys, then EVERY package the old host had
#              (dpkg selections; kernel-versioned ones excluded), versions
#              compared afterwards; snaps with their full channels
#   files      lay down files/ (minus RESTORE_SKIP), then the SQLite snapshots
#   ownership  where an account's id differs on this host (accounts built into
#              the base image), re-own the restored files by account name
#   databases  recreate each Postgres cluster and load its dump; Redis dumps
#   services   daemon-reload, start what was running, compare listening ports
#
# It never touches RESTORE_SKIP paths (disks, NICs, hostname, boot): those
# describe the old machine. Exit: 0 clean, 1 finished with warnings, 2 failed.
#
# --isolated is for restore drills: ISOLATE_UNITS from host.conf are masked
# before any service starts and ISOLATE_BLOCK_PORTS are blocked outbound, so
# the clone cannot act as the real host (mesh, onions, mail, posting, backups).
set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
# apt waits at most 10 minutes for the dpkg lock (first-boot unattended upgrades).
APT_CONFIG_LOCK_OPTS="-o DPkg::Lock::Timeout=600"
FROM=""; ONLY=""; DRY=0; YES=0; ISOLATED=0
while [[ $# -gt 0 ]]; do case "$1" in
  --from) FROM="$2"; shift ;; --only) ONLY="$2"; shift ;; --dry-run) DRY=1 ;; --yes) YES=1 ;; --isolated) ISOLATED=1 ;;
  *) echo "unknown arg $1" >&2; exit 2 ;; esac; shift; done
[[ -n "$FROM" && -f "$FROM/manifest.json" ]] || { echo "usage: hostrepo-restore --from <repo dir> (with manifest.json)" >&2; exit 2; }
# shellcheck source=/dev/null
. "$FROM/host.conf"
STATUS=0
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
warn() { [[ $STATUS -lt 1 ]] && STATUS=1; log "WARN: $*"; }
die() { log "FATAL: $*"; exit 2; }
run() { if [[ $DRY -eq 1 ]]; then printf '[dry-run] %s\n' "$*" >&2; else "$@"; fi; }
want() { [[ -z "$ONLY" || "$ONLY" == "$1" ]]; }
# Every systemctl action is bounded: some units stop with no timeout at all
# (Ubuntu's redis-server has TimeoutStopSec=infinity). A stop or restart that
# does not finish in 2 minutes is killed, and the restore goes on with a warning.
svc() {  # svc <action> <unit>
  [[ $DRY -eq 1 ]] && { printf '[dry-run] systemctl %s %s\n' "$1" "$2" >&2; return 0; }
  if timeout 120 systemctl "$1" "$2"; then return 0; fi
  warn "systemctl $1 $2 did not finish in 120s"
  if [[ "$1" == stop || "$1" == restart ]]; then
    systemctl kill -s KILL "$2" 2>/dev/null || true
    [[ "$1" == restart ]] && timeout 120 systemctl start "$2" && return 0
  fi
  return 1
}
[[ $EUID -eq 0 ]] || die "run as root"
M="$FROM/meta"

# ---- preflight -------------------------------------------------------------------
# shellcheck source=/dev/null
if ! ( . /etc/os-release; . "$M/os.env"
       if [[ "$ID" != "$id" || "$VERSION_ID" != "$version_id" ]]; then
         echo "OS mismatch: backup is $pretty, this host is $PRETTY_NAME" >&2; exit 1; fi ); then
  if [[ $YES -eq 1 ]]; then warn "continuing on a different OS release"; else die "OS mismatch (use --yes to continue anyway)"; fi
fi
log "restoring $HOST_ID from $FROM ($(sed -E 's/.*"made": "([^"]*)".*/\1/;q' "$FROM/manifest.json"))"
if [[ $YES -eq 0 && $DRY -eq 0 && -t 0 ]]; then
  read -r -p "This overwrites service files on $(hostname). Continue? [y/N] " a; [[ "$a" == y* ]] || exit 2
fi

# ---- isolation (drills) ---------------------------------------------------------------
# First, before packages can start anything: mask by name, even if the unit
# does not exist yet (a masked name blocks a unit installed later).
if [[ $ISOLATED -eq 1 ]]; then
  log "isolated: masking ${#ISOLATE_UNITS[@]} units, blocking ports ${ISOLATE_BLOCK_PORTS[*]} outbound"
  for u in "${ISOLATE_UNITS[@]}"; do run systemctl mask "$u" >/dev/null 2>&1 || true; done
  # Services bound to the old mesh addresses need them to exist; put them on
  # a local dummy interface instead of bringing up WireGuard.
  mesh_addrs=$(awk '$1 ~ /^wg/ {for (i=3; i<=NF; i++) print $i}' "$M/ip-addr.txt" | grep -v '^fe80')
  if [[ -n "$mesh_addrs" ]]; then
    run ip link add hr-drill type dummy 2>/dev/null || true; run ip link set hr-drill up
    for a in $mesh_addrs; do run ip addr add "$a" dev hr-drill 2>/dev/null || true; done
    log "isolated: old mesh addresses on dummy interface: $(echo $mesh_addrs)"
  fi
  if command -v nft >/dev/null; then
    ports=$(IFS=,; echo "${ISOLATE_BLOCK_PORTS[*]}")
    run nft add table inet hostrepo_drill
    run nft add chain inet hostrepo_drill out '{ type filter hook output priority 0; policy accept; }'
    run nft add rule inet hostrepo_drill out tcp dport "{ $ports }" drop
    run nft add rule inet hostrepo_drill out udp dport "{ $ports }" drop
  fi
fi

# ---- users -------------------------------------------------------------------------
# Runs before packages: accounts a package would create (postgres, redis,
# caddy...) get their original ids here, and the package then reuses them.
restore_users() {
  while IFS=: read -r name _ gid _; do
    if getent group "$name" >/dev/null; then continue
    elif getent group "$gid" >/dev/null; then warn "gid $gid is taken; group $name gets a new gid (ownership step fixes files)"; run groupadd -r "$name"
    else run groupadd -g "$gid" "$name"; fi
  done < "$M/group.txt"
  # Two passes: first every account whose original uid is free, then the ones
  # that must take a new uid, so a new uid can never be one a later account needs.
  local pass
  for pass in exact renumber; do
    while IFS=: read -r name _ uid gid gecos home shell; do
      getent passwd "$name" >/dev/null && continue
      local g="$gid"; getent group "$gid" >/dev/null || g="$name"
      if [[ $pass == exact ]]; then
        getent passwd "$uid" >/dev/null && continue
        run useradd -u "$uid" -g "$g" -d "$home" -s "$shell" -c "$gecos" -M "$name" || warn "could not create $name"
      else
        warn "uid $uid is taken; user $name gets a new uid (ownership step fixes files)"
        run useradd -r -g "$(getent group "$gid" | cut -d: -f1 || echo "$name")" -d "$home" -s "$shell" -c "$gecos" -M "$name" || warn "could not create $name"
      fi
      hash=$(awk -F: -v u="$name" '$1==u {print $2}' "$M/shadow.txt")
      [[ -n "$hash" && "$hash" != "!"* && "$hash" != "*" ]] && run usermod -p "$hash" "$name"
    done < "$M/passwd.txt"
  done
  while IFS=: read -r name _ _ members; do
    [[ -n "$members" ]] || continue
    for u in ${members//,/ }; do getent passwd "$u" >/dev/null && run gpasswd -a "$u" "$name" >/dev/null; done
  done < "$M/group.txt"
}
if want users; then log "users: recreating accounts with original ids"; restore_users; fi

# ---- packages ----------------------------------------------------------------------
if want packages; then
  log "packages: apt sources and keys"
  for p in etc/apt/sources.list etc/apt/sources.list.d etc/apt/keyrings etc/apt/trusted.gpg.d etc/apt/preferences.d usr/share/keyrings; do
    [[ -e "$FROM/files/$p" ]] && run rsync -a "$FROM/files/$p" "/$(dirname "$p")/"
  done
  # Mirrors that only answer inside the old provider's network (Hetzner's, say)
  # are set aside: "Enabled: no" in deb822 stanzas, commented out in .list
  # files. If nothing answers at all, the network is the problem; touch nothing.
  if [[ $DRY -eq 0 ]]; then
    mkdir -p "$FROM/meta/apt-sources-original"; cp -a /etc/apt/sources.list /etc/apt/sources.list.d "$FROM/meta/apt-sources-original/" 2>/dev/null || true
    disabled=$(python3 - <<'PY'
import glob, re, sys, urllib.request
def up(uri, suite):
    url = uri.rstrip("/") + "/dists/" + suite + "/Release"
    try:
        urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=10); return True
    except Exception:
        return False
sources = []   # (file, kind, index, uri, suite)
for f in glob.glob("/etc/apt/sources.list.d/*.sources"):
    for i, st in enumerate(re.split(r"\n\s*\n", open(f).read())):
        u = re.search(r"^URIs:\s*(\S+)", st, re.M); su = re.search(r"^Suites:\s*(\S+)", st, re.M)
        if u and su and not re.search(r"^Enabled:\s*no", st, re.M | re.I): sources.append((f, "sources", i, u.group(1), su.group(1)))
for f in ["/etc/apt/sources.list"] + glob.glob("/etc/apt/sources.list.d/*.list"):
    try: lines = open(f).read().splitlines()
    except OSError: continue
    for i, l in enumerate(lines):
        m = re.match(r"^\s*deb(-src)?\s+(\[[^]]*\]\s+)?(\S+)\s+(\S+)", l)
        if m: sources.append((f, "list", i, m.group(3), m.group(4)))
results = [(s, up(s[3], s[4])) for s in sources]
if not any(ok for _, ok in results):
    print("NONE-REACHABLE"); sys.exit(0)
for (f, kind, i, uri, suite), ok in results:
    if ok: continue
    if kind == "sources":
        parts = re.split(r"(\n\s*\n)", open(f).read())
        stanzas = parts[0::2]; stanzas[i] = "Enabled: no\n" + stanzas[i]
        parts[0::2] = stanzas; open(f, "w").write("".join(parts))
    else:
        lines = open(f).read().splitlines(); lines[i] = "# hostrepo: unreachable here: " + lines[i]
        open(f, "w").write("\n".join(lines) + "\n")
    print(f"{uri} ({f})")
PY
)
    if [[ "$disabled" == NONE-REACHABLE ]]; then warn "no apt source answered; network problem? sources left as they were"
    elif [[ -n "$disabled" ]]; then
      while IFS= read -r d; do log "packages: set aside unreachable mirror $d"; done <<<"$disabled"
      echo "$disabled" > "$FROM/meta/apt-sources-disabled.txt"
    fi
  fi
  run apt-get $APT_CONFIG_LOCK_OPTS update -qq || warn "apt-get update reported errors"
  # The whole package set, exactly as the old host had it. Kernel-versioned
  # packages stay behind: this machine keeps its own kernel.
  sel=$(mktemp)
  # Machine-specific: the kernel and the bootloader belong to the new machine.
  machine='^(linux-(generic|virtual|kvm|image|headers|modules|modules-extra|tools|cloud-tools|signed|main-modules|objects|buildinfo)([-.].*)?$|grub-|grub2|shim-|efibootmgr$|os-prober$)'
  awk '$2=="install" || $2=="hold"' "$M/dpkg-selections.txt" | grep -vE "$machine" > "$sel"
  log "packages: installing the old host's $(wc -l < "$sel") packages"
  if [[ $DRY -eq 0 ]]; then
    # dpkg ignores selections for packages missing from its own "available"
    # database, which apt does not maintain. Load it from apt first.
    apt-cache dumpavail > "$FROM/meta/restore-avail.txt" && dpkg --merge-avail "$FROM/meta/restore-avail.txt" >/dev/null 2>&1
    rm -f "$FROM/meta/restore-avail.txt"
    dpkg --set-selections < "$sel" 2> "$FROM/meta/restore-set-selections.err" || true
    # grub-pc on the new machine asks which disk to install to when it is
    # upgraded; unattended, that fails. Answer "none": the new machine's own
    # boot setup stays as the provider made it. (Holding the kernel and
    # bootloader instead leaves apt with an unresolvable set.)
    printf '%s\n' 'grub-pc grub-pc/install_devices_empty boolean true' 'grub-pc grub-pc/install_devices multiselect' |
      debconf-set-selections 2>/dev/null || true
    if ! apt-get $APT_CONFIG_LOCK_OPTS dselect-upgrade -y -q -o Dpkg::Options::=--force-confold >"$FROM/meta/restore-apt.log" 2>&1; then
      dpkg --configure -a >>"$FROM/meta/restore-apt.log" 2>&1 || true
      apt-get $APT_CONFIG_LOCK_OPTS -f install -y -q >>"$FROM/meta/restore-apt.log" 2>&1 || true
      if dpkg --audit | grep -q .; then warn "packages left unconfigured (see $FROM/meta/restore-apt.log)"; fi
    fi
    { grep -oE 'database at line [0-9]+: [^ ]+' "$FROM/meta/restore-set-selections.err" 2>/dev/null || true; } | awk '{print $NF}' | sort -u |
      while read -r p; do warn "package $p is not available here"; done
    xargs -a "$M/apt-manual.txt" apt-mark manual >/dev/null 2>&1 || true
    while read -r p; do apt-mark hold "$p" >/dev/null; done < <(cat "$M/apt-hold.txt" 2>/dev/null)
    # Same versions? Report every package that differs from the old host.
    dpkg-query -W -f='${Package}\t${Version}\n' | sort > "$FROM/meta/restore-versions.tsv"
    diffs=$(join -t $'\t' "$M/dpkg-versions.tsv" "$FROM/meta/restore-versions.tsv" | awk -F'\t' '$2 != $3' | wc -l)
    missing=$(join -t $'\t' -v1 <(grep -vE "$machine" "$M/dpkg-versions.tsv") "$FROM/meta/restore-versions.tsv" | wc -l)
    log "packages: $(wc -l < "$FROM/meta/restore-versions.tsv") installed; $diffs at a different version, $missing missing"
    if [[ $missing -gt 0 ]]; then warn "$missing packages from the old host are not installed (see meta/restore-versions.tsv)"; fi
  fi
  rm -f "$sel"
  # restic/sqlite3/rsync are needed by later steps and by future backups.
  run apt-get $APT_CONFIG_LOCK_OPTS install -y -qq rsync sqlite3 restic >/dev/null 2>&1 || true
  while IFS=$'\t' read -r name channel; do
    [[ "$name" == core* || "$name" == snapd || "$name" == bare ]] && continue
    snap list "$name" >/dev/null 2>&1 || run timeout 900 snap install "$name" --channel="${channel:-stable}" || warn "snap $name did not install (or took over 15 minutes)"
  done < "$M/snaps.tsv"
  # Packages may have been skipped; make sure every account exists now.
  want users || restore_users
fi

# ---- files -------------------------------------------------------------------------
if want files; then
  skip=(); for s in "${RESTORE_SKIP[@]}"; do skip+=(--exclude="$s"); done
  log "files: laying down $(wc -l < "$M/included-paths.txt") trees"
  # /etc is mirrored exactly: files the old host did not have (a package's
  # default site the old host had removed, say) are moved to displaced/, never
  # deleted. RESTORE_SKIP paths are excluded, so rsync leaves them alone.
  displaced="$(dirname "$FROM")/displaced-$(date -u +%Y%m%dT%H%M%SZ)"
  # Filter patterns are relative to the transfer root, which here is /etc:
  # /etc/machine-id must be written /machine-id, or it is silently not skipped.
  etc_skip=(); for sk in "${RESTORE_SKIP[@]}"; do [[ "$sk" == /etc/* ]] && etc_skip+=(--exclude="${sk#/etc}"); done
  run rsync -aHAX --numeric-ids --delete --backup --backup-dir="$displaced/etc" "${etc_skip[@]}" "$FROM/files/etc/" /etc/
  run rsync -aHAX --numeric-ids "${skip[@]}" --exclude=/etc "$FROM/files/" /
  [[ -d "$displaced" ]] && log "files: moved $(find "$displaced" -type f | wc -l) files the old host did not have to $displaced"
  # systemd must see the restored unit files and drop-ins before any step
  # below touches a service.
  run systemctl daemon-reload
  # Restored /etc/systemd may carry enable symlinks for isolated units; mask again.
  if [[ $ISOLATED -eq 1 ]]; then for u in "${ISOLATE_UNITS[@]}"; do run systemctl mask "$u" >/dev/null 2>&1 || true; done; fi
  if [[ -d "$FROM/db/sqlite" ]]; then
    log "files: SQLite snapshots over their live copies"
    run rsync -a --numeric-ids "$FROM/db/sqlite/" /
  fi
fi

# ---- ownership -------------------------------------------------------------------
# Files came back with the old host's numeric owners. For every account whose
# id differs here, re-own by name. Decisions use each file's ORIGINAL owner,
# recorded before any change, so swapped ids cannot cascade.
if want ownership; then
  log "ownership: mapping old ids to this host's accounts"
  run python3 - "$M/passwd.txt" "$M/group.txt" "$M/included-paths.txt" "$FROM/files/var/log" <<'PY'
import grp, os, pwd, stat, sys
def ids(path, byname):
    m = {}
    for line in open(path):
        f = line.rstrip("\n").split(":")
        if len(f) < 3: continue
        try: new = byname(f[0])
        except KeyError: continue
        if int(f[2]) != new: m[int(f[2])] = new
    return m
umap = ids(sys.argv[1], lambda n: pwd.getpwnam(n).pw_uid)
gmap = ids(sys.argv[2], lambda n: grp.getgrnam(n).gr_gid)
roots = [l.strip() for l in open(sys.argv[3]) if l.strip()]
def entries(root):
    """Every path under root, symlinks included but never followed."""
    if not os.path.lexists(root): return
    yield root
    if os.path.islink(root) or not os.path.isdir(root): return
    for dp, dns, fns in os.walk(root):
        for x in dns + fns: yield os.path.join(dp, x)
todo = []
for root in roots:
    for n in entries(root):
        st = os.lstat(n)
        u, g = umap.get(st.st_uid, st.st_uid), gmap.get(st.st_gid, st.st_gid)
        if (u, g) != (st.st_uid, st.st_gid): todo.append((n, u, g, st.st_mode))
# /var/log came back as directories only (no contents), so it is not in
# included-paths. Re-own just those directories, deciding from the owner in
# the repository copy. Never walk the live /var/log: its files belong to THIS
# host, and an old id can collide with a new one (a redis that was uid 100 on
# the old host, where uid 100 is syslog on a fresh Ubuntu image).
logs = sys.argv[4]
for dp, _, _ in os.walk(logs):
    n = os.path.normpath(os.path.join("/var/log", os.path.relpath(dp, logs)))
    if os.path.islink(n) or not os.path.isdir(n): continue
    st = os.lstat(dp)
    u, g = umap.get(st.st_uid, st.st_uid), gmap.get(st.st_gid, st.st_gid)
    if (u, g) != (st.st_uid, st.st_gid): todo.append((n, u, g, os.lstat(n).st_mode))
for n, u, g, mode in todo:
    os.lchown(n, u, g)
    if not stat.S_ISLNK(mode) and mode & (stat.S_ISUID | stat.S_ISGID):
        os.chmod(n, stat.S_IMODE(mode))   # chown clears setuid/setgid; put them back
print(f"re-owned {len(todo)} paths ({len(umap)} uids, {len(gmap)} gids differ)", file=sys.stderr)
PY
fi

# ---- log files ---------------------------------------------------------------------
# Log files the old host had, recreated empty with their owner and mode (by
# account name), where the new host has none. Some daemons will not start
# without the log they watch (fail2ban). Rotated copies were never listed.
if want ownership && [[ -s "$M/var-log-files.tsv" ]]; then
  made=0
  while IFS=$'\t' read -r u g mode p; do
    [[ "$p" == /var/log/* && ! -e "$p" && -d "$(dirname "$p")" ]] || continue
    if [[ $DRY -eq 0 ]]; then
      install -m "$mode" -o "$(id -u "$u" 2>/dev/null || echo 0)" -g "$(getent group "$g" | cut -d: -f3 || echo 0)" /dev/null "$p" 2>/dev/null && made=$((made+1))
    else made=$((made+1)); fi
  done < "$M/var-log-files.tsv"
  log "log files: recreated $made empty log files the old host had"
fi

# ---- databases ---------------------------------------------------------------------
if want databases; then
  for d in "$FROM"/db/postgres/*/; do
    [[ -f "$d/cluster.env" && -f "$d/dumpall.sql" ]] || continue
    # shellcheck source=/dev/null
    . "$d/cluster.env"
    log "postgres: $version/$name on :$port"
    # Exists only if its data directory does; restored config alone does not count.
    if [[ ! -f "$datadir/PG_VERSION" ]]; then
      conf="/etc/postgresql/$version/$name"
      # The files step restored the cluster's config; pg_createcluster wants
      # an empty spot, so move it aside, create, then put it back.
      [[ -d "$conf" ]] && run mv "$conf" "$conf.restored"
      run pg_createcluster "$version" "$name" -p "$port" -d "$datadir" >/dev/null
      if [[ -d "$conf.restored" ]]; then run rsync -a "$conf.restored/" "$conf/"; run rm -rf "$conf.restored"; fi
    fi
    if ! svc start "postgresql@$version-$name"; then
      warn "postgres $version/$name did not start: $(journalctl -u "postgresql@$version-$name" -n 2 --no-pager -o cat | tail -1)"; continue
    fi
    if [[ $DRY -eq 0 ]]; then
      # Root opens the dump and feeds it on stdin: the postgres user cannot
      # read the restore directory (0700 root), and must not need to.
      rc=0; sudo -u postgres psql -X -q -p "$port" postgres < "$d/dumpall.sql" > "$d/restore.log" 2>&1 || rc=$?
      [[ $rc -eq 0 ]] || warn "postgres $version/$name: psql exited $rc (see $d/restore.log)"
      errs=$(grep -ciE '(^|: )error' "$d/restore.log" || true)
      # With --clean, dropping and recreating the bootstrap role can error harmlessly.
      [[ $errs -le 3 ]] || warn "postgres $version/$name: $errs errors (see $d/restore.log)"
      # Every database in the dump must now exist.
      want_dbs=$(grep -oE '^CREATE DATABASE [^ ]+' "$d/dumpall.sql" | awk '{print $3}' | tr -d '"' | sort -u)
      have_dbs=$(sudo -u postgres psql -XAtp "$port" -c 'select datname from pg_database' | sort -u)
      gone=$(comm -23 <(echo "$want_dbs") <(echo "$have_dbs") | grep . | paste -sd' ' || true)
      if [[ -n "$gone" ]]; then warn "postgres $version/$name: databases missing after restore: $gone"
      else log "postgres $version/$name: $(echo "$want_dbs" | grep -c .) databases restored"; fi
    fi
  done
  for env in "$FROM"/db/redis/*.env; do
    [[ -e "$env" ]] || continue
    # shellcheck source=/dev/null
    . "$env"
    log "redis :$port -> $dir/$dbfilename"
    # unit comes from the .env written at backup time.
    [[ -n "${unit:-}" ]] && { svc stop "$unit" || true; }
    run mkdir -p "$dir"; run cp "${env%.env}.rdb" "$dir/$dbfilename"; run chown redis:redis "$dir/$dbfilename"
    [[ -n "${unit:-}" ]] && { svc start "$unit" || true; }
  done
fi

# ---- services ------------------------------------------------------------------------
if want services; then
  run systemctl daemon-reload
  log "services: starting what was running"
  failed=()
  while read -r u; do
    [[ "$u" == user@* || "$u" == getty@* ]] && continue
    if [[ $ISOLATED -eq 1 ]] && printf '%s\n' "${ISOLATE_UNITS[@]}" | grep -qxF "$u"; then continue; fi
    run systemctl enable "$u" >/dev/null 2>&1 || true
    # Restart, not just start: daemons the package install already started
    # (nginx, redis, postfix...) are still running the package's default
    # config, not the restored one.
    svc restart "$u" || failed+=("$u")
  done < "$M/running-services.txt"
  [[ ${#failed[@]} -gt 0 ]] && warn "services that did not start: ${failed[*]}"
  if [[ $DRY -eq 0 ]]; then
    sleep 5
    was=$(awk '{print $5}' "$M/listening.txt" | sed -E 's/.*:([0-9]+)$/\1/' | sort -u)
    now=$(ss -ltnupH | awk '{print $5}' | sed -E 's/.*:([0-9]+)$/\1/' | sort -u)
    missing=$(comm -23 <(echo "$was") <(echo "$now") | paste -sd' ')
    [[ -n "$missing" ]] && warn "ports that listened before but not now: $missing"
  fi
fi

log "restore finished with status $STATUS"
cat >&2 <<EOF

Checklist (not done automatically):
  - Network: this host keeps its own addresses. Old ones: $M/ip-addr.txt
  - Firewall: old rules for reference in $M/nft.txt and $M/iptables.txt
  - DNS: point records at this host once services check out
  - Mesh (WireGuard/BIRD): peers pin the old public IP; update their endpoints
  - Skipped on purpose: ${RESTORE_SKIP[*]}
  - apt mirrors set aside as unreachable here: $(cat "$FROM/meta/apt-sources-disabled.txt" 2>/dev/null | paste -sd';' - || true)
    (originals in $FROM/meta/apt-sources-original/; remove "Enabled: no" to use them again)
EOF
exit $STATUS
