#!/usr/bin/env bash # recovery/recover.sh — a blank Ubuntu 26.04 machine to a restored VENZARI VPS with ONE passphrase and no tokens. # # curl -fsSL https://recover.venzari.co -o recover.sh ; less recover.sh ; sudo bash recover.sh # sudo bash recover.sh --kit /path/to/recovery-kit.gpg (kit already downloaded) # # This file is PUBLIC and holds no secret. What it does, in order: # 1 installs the tools (Docker + compose, restic, gnupg, jq, git; rclone from rclone.org pinned to a version with its SHA-256 checked, not apt's) # 2 asks for the recovery passphrase ONCE; from it computes the hidden address of the kit (scrypt) and downloads the encrypted kit from our own Cloudflare: # nothing is listed or public, a wrong passphrase finds nothing # 3 opens the kit with the same passphrase: the restic passphrase, repository A's rclone remote, repository B's Backblaze keys # 4 proves it can reach a backup, pulls the restore tooling OUT of the latest snapshot (the stack repo is private; its token comes back with the restore) # 5 runs `restore.sh --execute` as user billy: secrets, files, databases, volumes, every repo cloned, the email system deployed and verified # 6 removes the opened kit. The passphrase is never written to disk and never in an argument. # Pattern: pinned downloads and one bootstrap script as in https://github.com/caiodassumpcao/drkit/pull/13 (read 2026-11-13). set -uo pipefail RCLONE_VERSION="v1.74.2" RECOVER_HOST="https://recover.venzari.co" START_SERVICES=1 KIT=""; while [ $# -gt 0 ]; do case "$1" in --no-services) START_SERVICES=0 ;; --kit) KIT="${2:?--kit needs a file or URL}"; shift ;; -h|--help) sed -n 2,16p "$0"; exit 0 ;; *) echo "unknown option $1" >&2; exit 2 ;; esac; shift; done step() { printf '\n== %s [t+%ss]\n' "$*" "$SECONDS"; }; die() { echo "recover: $*" >&2; exit 1; } [ "$(id -u)" -eq 0 ] || die "run as root (sudo bash recover.sh)" umask 077; RK=/root/.recovery-kit; trap 'rm -rf "$RK" /root/.rk-gnupg' EXIT step "1 tools" export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq docker.io docker-compose-v2 restic gnupg jq git curl python3-yaml openssl sudo >/var/tmp/recover-apt.log 2>&1 || die "apt install failed: see /var/tmp/recover-apt.log" if ! rclone version 2>/dev/null | head -1 | grep -q "$RCLONE_VERSION"; then cd /tmp && curl -fsSL -o rclone.deb "https://downloads.rclone.org/$RCLONE_VERSION/rclone-$RCLONE_VERSION-linux-amd64.deb" \ && curl -fsSL "https://downloads.rclone.org/$RCLONE_VERSION/SHA256SUMS" | grep "rclone-$RCLONE_VERSION-linux-amd64.deb" | sed 's# .*# rclone.deb#' | sha256sum -c - >/dev/null \ && dpkg -i rclone.deb >/dev/null || die "could not install rclone $RCLONE_VERSION (checksum or download failed)" fi # Ubuntu 26.04 no longer ships /etc/timezone, and the compose files bind-mount it: Docker would create a DIRECTORY there and the container fails with "not a directory" [ -d /etc/timezone ] && rmdir /etc/timezone 2>/dev/null [ -f /etc/timezone ] || echo "America/Chicago" > /etc/timezone [ -e /etc/localtime ] || ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime id billy >/dev/null 2>&1 || useradd -m -s /bin/bash -G docker,sudo billy echo "billy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/billy; chmod 440 /etc/sudoers.d/billy echo " ok: docker, restic, rclone $(rclone version | head -1 | awk '{print $2}'), user billy" step "2+3 the recovery passphrase: find the kit, open it" mkdir -p "$RK"; chmod 700 "$RK" export GNUPGHOME=/root/.rk-gnupg; mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" address() { printf '%s' "$1" | python3 -c 'import sys,hashlib; print(hashlib.scrypt(sys.stdin.buffer.read(), salt=b"venzari-recovery-address-v1", n=2**15, r=8, p=1, dklen=32, maxmem=2**26).hex())'; } opened=0 for try in 1 2 3; do if [ -n "${RECOVER_PASSPHRASE_FILE:-}" ]; then PW=$(cat "$RECOVER_PASSPHRASE_FILE"); else printf 'Recovery passphrase: ' > /dev/tty; IFS= read -rs PW < /dev/tty; printf '\n' > /dev/tty; fi if [ -n "$KIT" ] && [ -f "$KIT" ]; then cp "$KIT" "$RK/kit.gpg" else code=$(curl -s --max-time 60 -o "$RK/kit.gpg" -w '%{http_code}' "$RECOVER_HOST/k/$(address "$PW")" 2>/dev/null) if [ "$code" != 200 ]; then rm -f "$RK/kit.gpg"; echo " no kit found for this passphrase (a typo? the passphrase was changed?) [$try of 3]" >&2; [ -n "${RECOVER_PASSPHRASE_FILE:-}" ] && break; continue; fi fi if gpg --batch --yes --quiet --pinentry-mode loopback --passphrase-fd 3 -d -o "$RK/kit.tar" "$RK/kit.gpg" 3< <(printf '%s' "$PW") 2>/dev/null; then opened=1; break; fi echo " the kit did not open ($try of 3)" >&2 done PW=""; [ "$opened" = 1 ] || die "no kit could be found and opened (or give the kit file: sudo bash recover.sh --kit /path/to/recovery-kit.gpg)" tar -C "$RK" -xf "$RK/kit.tar" && rm -f "$RK/kit.tar" || die "could not unpack the kit" install -d -m 700 -o billy -g billy /opt/secrets /home/billy/.config/rclone install -m 600 -o billy -g billy "$RK/restic-passphrase" /opt/secrets/restic-passphrase install -m 600 -o billy -g billy "$RK/rclone.conf" /home/billy/.config/rclone/rclone.conf set -a; . <(grep -E '^(B2_ACCOUNT2_KEY_ID|B2_ACCOUNT2_APP_KEY|REPO_A|REPO_B)=' "$RK/access.env"); set +a echo " ok: restic passphrase, rclone remote and Backblaze keys installed for user billy" step "4 reach a backup, pull the restore tooling from it" run_billy() { sudo -u billy -H env B2_ACCOUNT_ID="${B2_ACCOUNT2_KEY_ID:-}" B2_ACCOUNT_KEY="${B2_ACCOUNT2_APP_KEY:-}" RESTIC_PASSWORD_FILE=/opt/secrets/restic-passphrase "$@"; } REPO="" for r in "$REPO_A" "$REPO_B"; do if run_billy restic -r "$r" snapshots --no-lock --tag venzari --latest 1 >/dev/null 2>&1; then REPO="$r"; echo " reached $r"; break; else echo " cannot reach $r" >&2; fi done [ -n "$REPO" ] || die "neither backup repository could be opened with the kit (check the network; or the kit is stale: the keys were rotated)" T=/home/billy/restore-kit; sudo -u billy mkdir -p "$T.tmp" run_billy restic -r "$REPO" restore latest --no-lock --tag venzari --target "$T.tmp" --include '*/restore-kit/restore-kit.tgz' >/dev/null 2>&1 || die "the snapshot has no restore-kit (a snapshot older than 2026-11-13: use the stack repo by hand)" tgz=$(find "$T.tmp" -name restore-kit.tgz | head -1); [ -n "$tgz" ] || die "restore-kit.tgz not found in the snapshot" sudo -u billy mkdir -p "$T" && sudo -u billy tar -xzf "$tgz" -C "$T" && echo " ok: restore tooling unpacked to $T" step "5 restore (restore.sh --execute as billy)" # no database container is prepared here: restore.sh phase 5 brings up the REAL venzari-db from its compose file (after the repos are cloned) cd "$T" && run_billy env RESTORE_PASSPHRASE_SOURCE=kit RESTORE_REPO="$REPO" RESTORE_START_SERVICES="$START_SERVICES" bash recovery/restore.sh --execute rc=$? step "6 done (restore.sh exit $rc)" echo "the opened kit is removed on exit. Next: bash $T/recovery/check-all-back.sh" exit $rc