Extends the "ADDITIONAL MIRROR" section (previously SFTP-only) with a
type choice: SFTP, or S3 against a Garage instance already running on
that box. For the S3 path, this script never asks the operator to retype
a bucket name or key — it SSHes to the destination, reads
~/docker/garage/.env directly (the real, currently-configured values,
generated once by services/garage.sh and never touched again on its own
Update runs), and uses those for the dry-run verification and the
persisted mirror args. If Garage isn't installed there yet, it says so
plainly with the exact install command instead of failing cryptically or
silently skipping.
Also removed the last hardcoded suggestions from services/garage.sh
itself ("kopia-backup" / "kopia" as fixed prompt defaults) — replaced
with a freshly-generated suggestion each run (timestamp-suffixed), so
nothing about the bucket/key name is a fixed string baked into this repo
at any point in the chain; it's always the operator's actual choice, read
back live wherever it's needed.
Verified end-to-end against a mocked ssh (returning realistic
~/docker/garage/.env content) covering both outcomes: Garage installed
with a real bucket/key correctly parsed, dry-run run, and persisted; and
Garage missing, correctly warning with the install command and leaving
backup.conf untouched either way.
1507 lines
82 KiB
Bash
1507 lines
82 KiB
Bash
#!/bin/bash
|
|
# services/backup.sh — Full Docker-service backup via Kopia.
|
|
# Part of the modular post-install system (sourced by setup.sh).
|
|
#
|
|
# Can also be run standalone on any machine:
|
|
# sudo bash backup.sh
|
|
# (Docker must already be installed when run standalone)
|
|
#
|
|
# Backs up each entire ~/docker/<service>/ directory (compose file, config, data,
|
|
# databases — everything needed to restore from nothing). Per-service behaviour:
|
|
# Minecraft instances — flush world to disk (save-all), snapshot, no downtime
|
|
# All other services — stop, snapshot, restart (seconds of downtime each)
|
|
#
|
|
# Different services can be routed to different Kopia repos / drives.
|
|
# New services are auto-discovered on every run — no reconfiguration needed.
|
|
#
|
|
# Creates: ~/docker/backup/
|
|
# backup.conf settings + per-service destination map (chmod 600)
|
|
# backup_kopia.sh worker (run directly or via systemd timer)
|
|
# restore_kopia.sh interactive restore helper
|
|
|
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
|
# Sets up helpers and globals, then defers execution until after the function
|
|
# definition at the bottom of this file.
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
|
|
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
|
|
|
if [[ -f "$_COMMON" ]]; then
|
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
|
# shellcheck source=../lib/common.sh
|
|
source "$_COMMON"
|
|
else
|
|
# One-off copy — inline minimal stubs so the script works without the repo
|
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
|
|
|
require_docker() {
|
|
command -v docker &>/dev/null || {
|
|
log_error "Docker not found. Install it first:"
|
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
|
return 1
|
|
}
|
|
docker compose version &>/dev/null || {
|
|
log_error "Docker Compose plugin missing:"
|
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
ensure_docker_dir_ownership() {
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
|
}
|
|
|
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
|
prompt_text() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
prompt_yn() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
configure_caddy_for_service() {
|
|
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
|
local _caddy_dir="$DOCKER_DIR/caddy"
|
|
local _caddyfile="$_caddy_dir/Caddyfile"
|
|
local _display_port="${_upstream##*:}"
|
|
|
|
# Determine mode: local Caddy, remote Caddy, or none
|
|
local _mode="none"
|
|
[[ -d "$_caddy_dir" ]] && _mode="local"
|
|
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
|
[[ "$_mode" == "none" ]] && {
|
|
log_info "Access $_name directly on port $_display_port."
|
|
return 0
|
|
}
|
|
|
|
echo ""
|
|
local _do_caddy=""
|
|
if [[ "$_mode" == "remote" ]]; then
|
|
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
|
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
|
fi
|
|
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
|
log_info "Skipping — access at: http://localhost:$_display_port"
|
|
return 0
|
|
}
|
|
|
|
# Domain prompt — pre-fill from SITE_DOMAIN when available
|
|
local _default_domain=""
|
|
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
|
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
|
log_info "Default: $_default_domain"
|
|
fi
|
|
local _domain=""
|
|
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
|
_domain="${_domain:-$_default_domain}"
|
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
|
|
|
# Build upstream — remote Caddy uses host IP:port, not container name
|
|
local _block_upstream="$_upstream"
|
|
if [[ "$_mode" == "remote" ]]; then
|
|
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
|
fi
|
|
|
|
local _site_block
|
|
_site_block="$(cat << CBLOCK
|
|
|
|
# $_name
|
|
${_domain} {
|
|
reverse_proxy ${_block_upstream}
|
|
|
|
header {
|
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
X-Content-Type-Options "nosniff"
|
|
X-Frame-Options "SAMEORIGIN"
|
|
Referrer-Policy "strict-origin-when-cross-origin"
|
|
}
|
|
|
|
log {
|
|
output file /var/log/caddy/${_domain}.log
|
|
format json
|
|
}
|
|
${_extra}
|
|
}
|
|
CBLOCK
|
|
)"
|
|
|
|
if [[ "$_mode" == "local" ]]; then
|
|
if [[ -f "$_caddyfile" ]]; then
|
|
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
|
cp "$_caddyfile" "$_bk"
|
|
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
|
else
|
|
touch "$_caddyfile"
|
|
fi
|
|
|
|
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
|
log_warning "$_domain already in Caddyfile"
|
|
local _ow=""
|
|
read -r -p " Overwrite? [y/N]: " _ow
|
|
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
|
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
|
fi
|
|
|
|
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
|
log_success "Added $_domain to Caddyfile"
|
|
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
|
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
|
log_success "$_name accessible at: https://$_domain"
|
|
else
|
|
log_warning "Reload failed — check: docker logs caddy"
|
|
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
|
fi
|
|
else
|
|
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
|
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
|
mkdir -p "$_snippet_dir"
|
|
printf '%s\n' "$_site_block" > "$_snippet_file"
|
|
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
|
log_success "Snippet saved: $_snippet_file"
|
|
log_info "Copy to Caddy machine:"
|
|
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
|
|
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
|
|
fi
|
|
}
|
|
write_readme() {
|
|
local _dir="$1"; shift
|
|
mkdir -p "$_dir"
|
|
cat > "$_dir/README.md"
|
|
}
|
|
|
|
generate_password() {
|
|
local _len="${1:-32}"
|
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
|
}
|
|
fi
|
|
|
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
|
# ($HOME under sudo is /root, not the real user's home)
|
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
|
DRY_RUN="${DRY_RUN:-false}"
|
|
UNATTENDED="${UNATTENDED:-false}"
|
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
|
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
|
|
|
register_service() { :; } # no-op — no wizard to register into
|
|
_RUN_STANDALONE=1
|
|
fi
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
register_service backup backup "Encrypted backup of all Docker services (full restore)"
|
|
|
|
# Ensures root has an SSH key usable for the systemd-run (root, unattended)
|
|
# backup/mirror steps. The DR-spare and SFTP-mirror prompts both need one —
|
|
# both used to check ONLY /root/.ssh, missing the common case where
|
|
# whoever ran `sudo ./setup.sh backup` already has a key under their OWN
|
|
# home directory (used interactively, quite possibly already authorized on
|
|
# the target box) while root — who actually runs the scheduled service —
|
|
# has none. Prefers reusing that existing keypair over minting a fresh
|
|
# one, since the existing one may already be trusted where it's needed;
|
|
# ssh-copy-id-ing a brand new key is the fallback, not the first move.
|
|
#
|
|
# Takes an optional target host as $1. Confirmed live: root can already
|
|
# `ssh main` successfully — proven by this same script's own DR-spare sync
|
|
# succeeding — while root has neither /root/.ssh/id_ed25519 nor id_rsa. The
|
|
# working key just has some other filename (e.g. a ~/.ssh/config alias
|
|
# pointing at it). Without checking this first, the function would offer to
|
|
# generate/copy a redundant key instead of reusing the one that's already
|
|
# trusted where it's needed. When a host is given, this tests root's SSH
|
|
# access to it as-is and resolves the actual key ssh would use via `ssh -G`
|
|
# before falling back to the copy/generate prompts.
|
|
#
|
|
# Sets _ROOT_SSH_KEYFILE (out-param, not local) to the resulting keyfile
|
|
# path, empty if none is available/created.
|
|
_backup_ensure_root_ssh_key() {
|
|
local _target_host="${1:-}"
|
|
_ROOT_SSH_KEYFILE=""
|
|
if [ -f /root/.ssh/id_ed25519 ]; then
|
|
_ROOT_SSH_KEYFILE=/root/.ssh/id_ed25519; return 0
|
|
fi
|
|
if [ -f /root/.ssh/id_rsa ]; then
|
|
_ROOT_SSH_KEYFILE=/root/.ssh/id_rsa; return 0
|
|
fi
|
|
|
|
if [ -n "$_target_host" ] && ssh -o BatchMode=yes -o ConnectTimeout=5 "$_target_host" true 2>/dev/null; then
|
|
local _resolved_key
|
|
_resolved_key="$(ssh -G "$_target_host" 2>/dev/null | awk '/^identityfile /{print $2; exit}')"
|
|
_resolved_key="${_resolved_key/#\~/\/root}"
|
|
if [ -n "$_resolved_key" ] && [ -f "$_resolved_key" ]; then
|
|
log_success " root already has working SSH access to $_target_host via $_resolved_key — reusing it, not generating a new one."
|
|
_ROOT_SSH_KEYFILE="$_resolved_key"
|
|
return 0
|
|
fi
|
|
log_warning " root can already SSH to $_target_host, but its actual identity file (agent-based auth?)"
|
|
log_warning " can't be resolved to a file kopia can use directly — falling back below."
|
|
fi
|
|
|
|
local _user_key=""
|
|
[ -f "$ACTUAL_HOME/.ssh/id_ed25519" ] && _user_key="$ACTUAL_HOME/.ssh/id_ed25519"
|
|
[ -z "$_user_key" ] && [ -f "$ACTUAL_HOME/.ssh/id_rsa" ] && _user_key="$ACTUAL_HOME/.ssh/id_rsa"
|
|
|
|
if [ -n "$_user_key" ]; then
|
|
local _COPY_USER_KEY=""
|
|
prompt_yn " No SSH key for root, but $ACTUAL_USER has one ($_user_key) — reuse it for root too (it may already be authorized where you need it)? (y/n):" "y" _COPY_USER_KEY
|
|
if [[ "$_COPY_USER_KEY" =~ ^[Yy]$ ]]; then
|
|
mkdir -p /root/.ssh && chmod 700 /root/.ssh
|
|
local _keyname; _keyname="$(basename "$_user_key")"
|
|
cp "$_user_key" "/root/.ssh/$_keyname"
|
|
[ -f "${_user_key}.pub" ] && cp "${_user_key}.pub" "/root/.ssh/${_keyname}.pub"
|
|
chown root:root "/root/.ssh/$_keyname" "/root/.ssh/${_keyname}.pub" 2>/dev/null
|
|
chmod 600 "/root/.ssh/$_keyname"
|
|
[ -f "/root/.ssh/${_keyname}.pub" ] && chmod 644 "/root/.ssh/${_keyname}.pub"
|
|
log_success " Copied $_user_key to /root/.ssh/ for root's use."
|
|
_ROOT_SSH_KEYFILE="/root/.ssh/$_keyname"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
local _GEN_KEY=""
|
|
prompt_yn " Generate a new SSH key for root (ssh-keygen)? (y/n):" "y" _GEN_KEY
|
|
if [[ "$_GEN_KEY" =~ ^[Yy]$ ]]; then
|
|
mkdir -p /root/.ssh && chmod 700 /root/.ssh
|
|
if ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_ed25519 -q; then
|
|
log_success " Generated /root/.ssh/id_ed25519"
|
|
_ROOT_SSH_KEYFILE=/root/.ssh/id_ed25519
|
|
else
|
|
log_warning " ssh-keygen failed — generate one manually."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
install_backup() {
|
|
require_docker || return 1
|
|
|
|
local DIR="$DOCKER_DIR/backup"
|
|
local CONF_FILE="$DIR/backup.conf"
|
|
local WORKER="$DIR/backup_kopia.sh"
|
|
local RESTORE="$DIR/restore_kopia.sh"
|
|
local DR_BRINGUP="$DIR/dr_bringup.sh"
|
|
local SVC_NAME="post-install-backup"
|
|
|
|
echo ""
|
|
echo "╔═══════════════════════════════════════════════════════════════════╗"
|
|
echo "║ BACKUP STRATEGIES — choose the right tool for your data ║"
|
|
echo "╚═══════════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " This installer sets up Kopia for full service backup, but here is"
|
|
echo " a quick reference to all available options."
|
|
echo ""
|
|
echo " ┌─────────────────────────────────────────────────────────────────┐"
|
|
echo " │ KOPIA (installed here) — block-level dedup + zstd + encryption │"
|
|
echo " │ Use for: files that change constantly — Minecraft worlds, │"
|
|
echo " │ databases, configs, entire Docker service directories. │"
|
|
echo " │ Changed blocks stored once; unchanged blocks share space. │"
|
|
echo " │ Restores via: restore_kopia_backup.sh (interactive) │"
|
|
echo " └─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
echo " ┌─────────────────────────────────────────────────────────────────┐"
|
|
echo " │ BORG (sudo apt install borgbackup) — chunk dedup + encryption │"
|
|
echo " │ Use for: same as Kopia. Choose Borg if you prefer Borgmatic │"
|
|
echo " │ (YAML config), Vorta (GUI), or multi-machine repos. │"
|
|
echo " │ borg init / borg create / borg list / borg extract │"
|
|
echo " └─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
echo " ┌─────────────────────────────────────────────────────────────────┐"
|
|
echo " │ RSYNC plain rsync -av --delete /src/ /dest/ │"
|
|
echo " │ Use for: media, ROMs, files that rarely change and you just │"
|
|
echo " │ need a copy. Fast, transparent — no special restore tool. │"
|
|
echo " │ Not suitable for files that change often (one bad --delete │"
|
|
echo " │ run immediately destroys the only copy in the destination). │"
|
|
echo " └─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
echo " ┌─────────────────────────────────────────────────────────────────┐"
|
|
echo " │ RSYNC --link-dest versioned snapshots, original folder layout │"
|
|
echo " │ Creates dated dirs (2024-01-15/, 2024-01-16/, …). │"
|
|
echo " │ Unchanged files are hard-linked — cost no extra disk space. │"
|
|
echo " │ Each dated dir is a complete, browsable snapshot. │"
|
|
echo " │ Use for: general files where you want versioning + readable │"
|
|
echo " │ snapshot dirs without a special restore tool. │"
|
|
echo " └─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
echo " ┌─────────────────────────────────────────────────────────────────┐"
|
|
echo " │ RSNAPSHOT (sudo apt install rsnapshot) — automated rotation │"
|
|
echo " │ Wraps rsync with a retention scheme (daily.0, weekly.0, …). │"
|
|
echo " │ Hard-links unchanged files; dirs named by rsnapshot. │"
|
|
echo " │ Use for: automated versioning without scripting --link-dest. │"
|
|
echo " └─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
echo " Quick reference:"
|
|
echo " Full service recovery, databases, configs → Kopia (this installer)"
|
|
echo " Frequently changing saves without downtime → gaming-backup service"
|
|
echo " Media / ROMs (rarely changes, just need a copy) → rsync plain"
|
|
echo " Versioned snapshots, keep original folder layout → rsync --link-dest"
|
|
echo " Versioned snapshots, want auto rotation scripted → rsnapshot"
|
|
echo ""
|
|
echo " Continuing with Kopia setup..."
|
|
echo ""
|
|
|
|
echo "╔═══════════════════════════════════════════════════════╗"
|
|
echo "║ Backup Setup · Kopia ║"
|
|
echo "║ Full ~/docker/<service>/ snapshots ║"
|
|
echo "╚═══════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " Backs up each entire service directory — compose file, config, data,"
|
|
echo " databases, everything needed to restore a service from scratch."
|
|
echo ""
|
|
echo " Minecraft: world flushed to disk (save-all), snapshot, NO downtime."
|
|
echo " Everything else: stopped briefly, snapshotted, restarted."
|
|
echo ""
|
|
echo " Different services can go to different drives / Kopia repos."
|
|
echo " New services are auto-detected on every run — no reconfiguration needed."
|
|
echo ""
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "[DRY-RUN] Would discover services under $DOCKER_DIR"
|
|
echo "[DRY-RUN] Would create $DIR with conf, worker, and restore scripts"
|
|
echo "[DRY-RUN] Would create Kopia repo(s) at user-specified paths"
|
|
echo "[DRY-RUN] Would install systemd timer"
|
|
return 0
|
|
fi
|
|
|
|
# ── 1. Kopia ─────────────────────────────────────────────────────────────
|
|
if ! command -v kopia >/dev/null 2>&1; then
|
|
log_info "Installing Kopia..."
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
install -d -m 0755 /etc/apt/keyrings
|
|
curl -fsSL https://kopia.io/signing-key \
|
|
| gpg --dearmor --yes -o /etc/apt/keyrings/kopia-keyring.gpg \
|
|
&& echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" \
|
|
> /etc/apt/sources.list.d/kopia.list \
|
|
&& apt-get update -y && apt-get install -y kopia
|
|
fi
|
|
fi
|
|
command -v kopia >/dev/null 2>&1 \
|
|
|| { log_error "Kopia not installed. See https://kopia.io/docs/installation/"; return 1; }
|
|
local KOPIA_BIN; KOPIA_BIN="$(command -v kopia)"
|
|
log_success "Kopia: $("$KOPIA_BIN" --version 2>/dev/null | head -1)"
|
|
|
|
# ── 2. Discover installed services ───────────────────────────────────────
|
|
local -a ALL_SVCS=()
|
|
local d svc
|
|
for d in "$DOCKER_DIR"/*/; do
|
|
[ -f "${d}docker-compose.yml" ] || continue
|
|
svc="$(basename "$d")"
|
|
[[ "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue
|
|
ALL_SVCS+=("$svc")
|
|
done
|
|
|
|
if [ "${#ALL_SVCS[@]}" -eq 0 ]; then
|
|
log_warning "No services found under $DOCKER_DIR — auto-detected on each backup run."
|
|
else
|
|
log_info "Services found: ${ALL_SVCS[*]}"
|
|
fi
|
|
|
|
# ── 3. Destinations ───────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " BACKUP DESTINATIONS"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Each destination is a Kopia repository directory."
|
|
echo " For best resilience: use a different drive or mount point from your data."
|
|
echo " Destination names must be letters, numbers, and underscores only."
|
|
echo ""
|
|
|
|
local DEFAULT_DEST="$ACTUAL_HOME/backups/kopia-backup"
|
|
if [ -f "$CONF_FILE" ]; then
|
|
local _existing_default_repo
|
|
_existing_default_repo="$(grep '^DEST_default_REPO=' "$CONF_FILE" 2>/dev/null | sed -E 's/^DEST_default_REPO="(.*)"$/\1/')"
|
|
[ -n "$_existing_default_repo" ] && DEFAULT_DEST="$_existing_default_repo"
|
|
fi
|
|
local _repo=""
|
|
prompt_text " Default repository path [${DEFAULT_DEST}]:" "$DEFAULT_DEST" _repo
|
|
_repo="${_repo/#\~/$ACTUAL_HOME}"; _repo="${_repo%/}"
|
|
|
|
local -a DEST_NAMES_ARR=("default")
|
|
local -A DEST_REPOS=() DEST_PASSWORDS=() DEST_CONFIGS=()
|
|
DEST_REPOS["default"]="$_repo"
|
|
DEST_CONFIGS["default"]="/etc/kopia-backup/default.config"
|
|
|
|
# Preserve any extra (non-"default") destinations already configured.
|
|
# This script has no update/fresh distinction, so without this,
|
|
# skipping the "Add more destinations?" prompt below on a rerun would
|
|
# silently drop every extra destination — and anything mapped to it —
|
|
# from the rewritten backup.conf, rather than just leaving it as-is.
|
|
if [ -f "$CONF_FILE" ]; then
|
|
local _existing_dest_names _en _existing_repo _existing_cfg
|
|
_existing_dest_names="$(grep '^DEST_NAMES=' "$CONF_FILE" 2>/dev/null | sed -E 's/^DEST_NAMES="(.*)"$/\1/')"
|
|
for _en in $_existing_dest_names; do
|
|
[ "$_en" = "default" ] && continue
|
|
_existing_repo="$(grep "^DEST_${_en}_REPO=" "$CONF_FILE" 2>/dev/null | sed -E "s/^DEST_${_en}_REPO=\"(.*)\"\$/\1/")"
|
|
[ -z "$_existing_repo" ] && continue
|
|
_existing_cfg="$(grep "^DEST_${_en}_CONFIG=" "$CONF_FILE" 2>/dev/null | sed -E "s/^DEST_${_en}_CONFIG=\"(.*)\"\$/\1/")"
|
|
DEST_REPOS["$_en"]="$_existing_repo"
|
|
DEST_CONFIGS["$_en"]="${_existing_cfg:-/etc/kopia-backup/${_en}.config}"
|
|
DEST_NAMES_ARR+=("$_en")
|
|
done
|
|
if [ "${#DEST_NAMES_ARR[@]}" -gt 1 ]; then
|
|
log_info " Keeping already-configured destination(s): ${DEST_NAMES_ARR[*]:1}"
|
|
fi
|
|
fi
|
|
|
|
local _extra=""
|
|
prompt_yn " Add more destinations (for services on different drives)? (y/N):" "n" _extra
|
|
if [[ "$_extra" =~ ^[Yy]$ ]]; then
|
|
echo ""
|
|
local _dn _dr
|
|
while true; do
|
|
prompt_text " Destination name (blank to finish):" "" _dn
|
|
[ -z "$_dn" ] && break
|
|
_dn="${_dn//[^a-zA-Z0-9_]/_}"
|
|
[ "$_dn" = "default" ] && { log_warning " 'default' is reserved — use another name."; continue; }
|
|
# Typing an already-known name reconfigures its path rather than
|
|
# duplicating it in DEST_NAMES_ARR.
|
|
prompt_text " Path for '$_dn' repository:" "${DEST_REPOS[$_dn]:-}" _dr
|
|
[ -z "$_dr" ] && continue
|
|
_dr="${_dr/#\~/$ACTUAL_HOME}"; _dr="${_dr%/}"
|
|
DEST_REPOS["$_dn"]="$_dr"
|
|
DEST_CONFIGS["$_dn"]="/etc/kopia-backup/${_dn}.config"
|
|
if [[ " ${DEST_NAMES_ARR[*]} " != *" $_dn "* ]]; then
|
|
DEST_NAMES_ARR+=("$_dn")
|
|
fi
|
|
log_success " Destination '$_dn' → $_dr"
|
|
done
|
|
fi
|
|
|
|
# ── 4. Service → destination assignment ──────────────────────────────────
|
|
local -A SVC_DEST_MAP=()
|
|
if [ "${#ALL_SVCS[@]}" -gt 0 ] && [ "${#DEST_NAMES_ARR[@]}" -gt 1 ]; then
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " ASSIGN SERVICES TO DESTINATIONS"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Destinations:"
|
|
local dn
|
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
|
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
|
done
|
|
echo ""
|
|
echo " Press Enter to accept the shown default for each service."
|
|
echo ""
|
|
local _d _svc_var _existing_svc_dest
|
|
for svc in "${ALL_SVCS[@]}"; do
|
|
_svc_var="${svc//-/_}"
|
|
_existing_svc_dest="default"
|
|
if [ -f "$CONF_FILE" ]; then
|
|
_existing_svc_dest="$(grep -E "^#?SVC_${_svc_var}=" "$CONF_FILE" 2>/dev/null | tail -1 | sed -E 's/^#?SVC_[A-Za-z0-9_]+="(.*)"$/\1/')"
|
|
[ -z "$_existing_svc_dest" ] && _existing_svc_dest="default"
|
|
fi
|
|
prompt_text " $svc [$_existing_svc_dest]:" "$_existing_svc_dest" _d
|
|
if [ -n "$_d" ] && [ "$_d" != "default" ] && [ -n "${DEST_REPOS[$_d]:-}" ]; then
|
|
SVC_DEST_MAP["$svc"]="$_d"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── 5. Passwords ─────────────────────────────────────────────────────────
|
|
# A destination's password is read back from an existing backup.conf
|
|
# (if this destination name was already configured) rather than
|
|
# re-prompted every run — this script has no update/fresh distinction,
|
|
# so re-running it (e.g. to add a destination, or just re-running it
|
|
# by habit) used to always mint a fresh/auto-generated password even
|
|
# for an already-existing repo. Confirmed live: that fresh password
|
|
# then fails to open the real repo at that path with "invalid
|
|
# repository password" — the repo's actual password was whatever got
|
|
# typed/generated the FIRST time, permanently, and nothing here ever
|
|
# read that back.
|
|
echo ""
|
|
log_info "Setting repository passwords (stored in backup.conf, chmod 600)..."
|
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
|
local pw=""
|
|
if [ -f "$CONF_FILE" ]; then
|
|
pw="$(grep "^DEST_${dn}_PASSWORD=" "$CONF_FILE" 2>/dev/null | sed -E "s/^DEST_${dn}_PASSWORD='(.*)'$/\1/")"
|
|
fi
|
|
if [ -n "$pw" ]; then
|
|
log_info " Reusing existing password for '$dn' (from $CONF_FILE)."
|
|
elif [ "$UNATTENDED" = true ]; then
|
|
pw="$(generate_password 32)"
|
|
else
|
|
read -rsp " Password for '$dn' [Enter = auto-generate]: " pw; echo
|
|
fi
|
|
[ -z "$pw" ] && pw="$(generate_password 32)" && log_info " Auto-generated password for '$dn'."
|
|
DEST_PASSWORDS["$dn"]="$pw"
|
|
done
|
|
|
|
# ── 6. Schedule ───────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " SCHEDULE & RETENTION"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Minecraft runs uninterrupted; other services stop briefly (seconds each)."
|
|
echo " Schedule for off-peak hours."
|
|
echo ""
|
|
echo " 1) Daily at 02:00 (recommended)"
|
|
echo " 2) Every 12 hours"
|
|
echo " 3) Weekly (Sunday 02:00)"
|
|
echo " 4) Custom (systemd OnCalendar)"
|
|
echo ""
|
|
# Preselect whatever's already scheduled, read back from the live timer
|
|
# unit rather than backup.conf (the schedule isn't stored there — it's
|
|
# baked directly into the .timer file). Without this, re-running the
|
|
# installer and just hitting Enter through this prompt would silently
|
|
# revert a customized schedule back to "1) Daily at 02:00" every time.
|
|
local _sch="1" _existing_oncal=""
|
|
if [ -f "/etc/systemd/system/${SVC_NAME}.timer" ]; then
|
|
_existing_oncal="$(grep '^OnCalendar=' "/etc/systemd/system/${SVC_NAME}.timer" 2>/dev/null | cut -d= -f2-)"
|
|
case "$_existing_oncal" in
|
|
"*-*-* 02,14:00:00") _sch="2" ;;
|
|
"Sun *-*-* 02:00:00") _sch="3" ;;
|
|
"*-*-* 02:00:00"|"") _sch="1" ;;
|
|
*) _sch="4" ;;
|
|
esac
|
|
fi
|
|
prompt_text " How often? [$_sch]:" "$_sch" _sch
|
|
local ONCALENDAR SCHED_LABEL
|
|
case "${_sch:-1}" in
|
|
2) ONCALENDAR="*-*-* 02,14:00:00"; SCHED_LABEL="every 12 hours" ;;
|
|
3) ONCALENDAR="Sun *-*-* 02:00:00"; SCHED_LABEL="weekly Sunday 02:00" ;;
|
|
4) prompt_text " OnCalendar expression:" "${_existing_oncal:-*-*-* 02:00:00}" ONCALENDAR; SCHED_LABEL="$ONCALENDAR" ;;
|
|
*) ONCALENDAR="*-*-* 02:00:00"; SCHED_LABEL="daily at 02:00" ;;
|
|
esac
|
|
local KEEP_LATEST=""
|
|
prompt_text " Snapshots to keep (latest)? [7]:" "7" KEEP_LATEST
|
|
KEEP_LATEST="${KEEP_LATEST:-7}"
|
|
|
|
# ── Notifications (ntfy) ─────────────────────────────────────────────────
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " NOTIFICATIONS (optional)"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Receive a push notification after every backup (and on failures)."
|
|
echo " Uses ntfy — free and self-hostable. Create a topic at https://ntfy.sh"
|
|
echo " Example URL: https://ntfy.sh/my-backup-alerts"
|
|
echo ""
|
|
local NTFY_URL="" NTFY_TOKEN=""
|
|
if [ -f "$CONF_FILE" ]; then
|
|
NTFY_URL="$(grep "^NTFY_URL=" "$CONF_FILE" 2>/dev/null | sed -E "s/^NTFY_URL='(.*)'\$/\1/")"
|
|
NTFY_TOKEN="$(grep "^NTFY_TOKEN=" "$CONF_FILE" 2>/dev/null | sed -E "s/^NTFY_TOKEN='(.*)'\$/\1/")"
|
|
fi
|
|
prompt_text " ntfy topic URL (blank to skip)${NTFY_URL:+ — already set to $NTFY_URL}:" "$NTFY_URL" NTFY_URL
|
|
if [ -n "$NTFY_URL" ]; then
|
|
prompt_text " ntfy access token (blank if public/no auth${NTFY_TOKEN:+ — one is already set, Enter keeps it}):" "$NTFY_TOKEN" NTFY_TOKEN
|
|
fi
|
|
|
|
# ── Disaster-recovery spare box (optional) ────────────────────────────────
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " DISASTER-RECOVERY SPARE (optional)"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " If you keep a spare box ready to take over on failure (running"
|
|
echo " dr_bringup.sh), this can push backup.conf + README.md to it after"
|
|
echo " every successful backup, so it's always ready without a manual copy."
|
|
echo ""
|
|
echo " Requires passwordless SSH (key-based) from THIS box to the spare,"
|
|
echo " as the account below. Since the backup timer runs as root, that"
|
|
echo " usually means a key in /root/.ssh authorized on the spare — set that"
|
|
echo " up first if you haven't (ssh-keygen, then ssh-copy-id to the spare)."
|
|
echo ""
|
|
local DR_SYNC_HOST="" DR_SYNC_PATH=""
|
|
if [ -f "$CONF_FILE" ]; then
|
|
DR_SYNC_HOST="$(grep '^DR_SYNC_HOST=' "$CONF_FILE" 2>/dev/null | sed -E "s/^DR_SYNC_HOST='(.*)'\$/\1/")"
|
|
DR_SYNC_PATH="$(grep '^DR_SYNC_PATH=' "$CONF_FILE" 2>/dev/null | sed -E "s/^DR_SYNC_PATH='(.*)'\$/\1/")"
|
|
fi
|
|
prompt_text " Spare box SSH destination, user@host (blank to skip):" "$DR_SYNC_HOST" DR_SYNC_HOST
|
|
if [ -n "$DR_SYNC_HOST" ]; then
|
|
prompt_text " Path for backup.conf/README on the spare:" "${DR_SYNC_PATH:-~/docker/backup}" DR_SYNC_PATH
|
|
DR_SYNC_PATH="${DR_SYNC_PATH:-~/docker/backup}"
|
|
|
|
# Catch a missing/unauthorized key now, not at 2am during the first
|
|
# scheduled backup. Non-fatal either way — the setting is saved
|
|
# regardless, since the key may simply not be set up yet.
|
|
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$DR_SYNC_HOST" true 2>/dev/null; then
|
|
log_success " SSH to $DR_SYNC_HOST works — spare sync will run after each backup."
|
|
else
|
|
log_warning " Couldn't SSH to $DR_SYNC_HOST without a password right now."
|
|
|
|
# If the spare isn't reachable at all (behind NAT, no port-forward —
|
|
# a home box is the common case), a passwordless key won't help
|
|
# until there's a network path there in the first place. Check
|
|
# for an already-running mesh VPN first — wg-easy (this repo's
|
|
# own), Netbird, or Tailscale are all common, and if the
|
|
# operator already has any ONE of them running, pushing them
|
|
# toward installing a second, redundant mesh would be actively
|
|
# wrong. Only offer a choice when none of the three are present.
|
|
local _VPN_DETECTED=""
|
|
if [ -d "$DOCKER_DIR/wg-easy" ]; then
|
|
_VPN_DETECTED="wg-easy"
|
|
elif command -v netbird >/dev/null 2>&1 && systemctl is-active --quiet netbird 2>/dev/null; then
|
|
_VPN_DETECTED="Netbird"
|
|
elif command -v tailscale >/dev/null 2>&1 && systemctl is-active --quiet tailscaled 2>/dev/null; then
|
|
_VPN_DETECTED="Tailscale"
|
|
fi
|
|
|
|
if [ -n "$_VPN_DETECTED" ]; then
|
|
log_info " Detected $_VPN_DETECTED already running — use its address for the spare box"
|
|
log_info " destination above instead of the public one, if you haven't already."
|
|
else
|
|
echo ""
|
|
echo " 1) wg-easy — this repo's own guided WireGuard hub (chain-installs now)"
|
|
echo " 2) Netbird — official installer (needs a setup key from your Netbird"
|
|
echo " account/self-hosted server — https://docs.netbird.io)"
|
|
echo " 3) Tailscale — official installer (opens an auth link to your account —"
|
|
echo " https://tailscale.com)"
|
|
echo " 4) Skip"
|
|
echo ""
|
|
local _VPN_CHOICE=""
|
|
prompt_text " Spare box not directly reachable? Set up a VPN mesh now [4]:" "4" _VPN_CHOICE
|
|
case "${_VPN_CHOICE:-4}" in
|
|
1)
|
|
if declare -F install_wg-easy >/dev/null 2>&1; then
|
|
install_wg-easy
|
|
else
|
|
log_warning " services/wg-easy.sh isn't loaded — run: sudo ./setup.sh wg-easy"
|
|
fi
|
|
;;
|
|
2)
|
|
curl -fsSL https://pkgs.netbird.io/install.sh | sh \
|
|
&& log_success " Netbird installed — finish setup with: netbird up --setup-key <YOUR_SETUP_KEY>" \
|
|
|| log_warning " Netbird install failed — see https://docs.netbird.io/get-started/install/linux"
|
|
;;
|
|
3)
|
|
curl -fsSL https://tailscale.com/install.sh | sh \
|
|
&& log_success " Tailscale installed — finish setup with: tailscale up" \
|
|
|| log_warning " Tailscale install failed — see https://tailscale.com/docs/install/linux"
|
|
;;
|
|
*) : ;;
|
|
esac
|
|
fi
|
|
|
|
_backup_ensure_root_ssh_key "$DR_SYNC_HOST"
|
|
|
|
local _COPY_KEY=""
|
|
prompt_yn " Run ssh-copy-id to $DR_SYNC_HOST now? (asks for its login password interactively) (y/n):" "y" _COPY_KEY
|
|
if [[ "$_COPY_KEY" =~ ^[Yy]$ ]]; then
|
|
if ssh-copy-id "$DR_SYNC_HOST"; then
|
|
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$DR_SYNC_HOST" true 2>/dev/null; then
|
|
log_success " SSH to $DR_SYNC_HOST now works — spare sync will run after each backup."
|
|
else
|
|
log_warning " ssh-copy-id reported success but the passwordless check still failed — check manually."
|
|
fi
|
|
else
|
|
log_warning " ssh-copy-id failed. Spare sync is saved but will fail until this works:"
|
|
log_warning " ssh-copy-id $DR_SYNC_HOST"
|
|
fi
|
|
else
|
|
log_warning " Spare sync is saved but will fail until this works (as root, since"
|
|
log_warning " the backup timer runs as root): ssh-copy-id $DR_SYNC_HOST"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
mkdir -p "$DIR"
|
|
ensure_docker_dir_ownership "$DIR"
|
|
|
|
local repo pw cfg
|
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
|
repo="${DEST_REPOS[$dn]}"
|
|
pw="${DEST_PASSWORDS[$dn]}"
|
|
cfg="${DEST_CONFIGS[$dn]}"
|
|
mkdir -p "$repo" "$(dirname "$cfg")" /var/cache/kopia-backup
|
|
|
|
kp_d() { env KOPIA_PASSWORD="$pw" "$KOPIA_BIN" --config-file="$cfg" "$@"; }
|
|
|
|
if kp_d repository status >/dev/null 2>&1; then
|
|
log_success "Connected to existing repo '$dn'."
|
|
elif test -e "$repo/kopia.repository.f"; then
|
|
log_info "Connecting to existing repo '$dn' at $repo ..."
|
|
kp_d repository connect filesystem --path="$repo" \
|
|
--cache-directory=/var/cache/kopia-backup \
|
|
|| { log_error "Failed to connect to '$dn' repo."; return 1; }
|
|
else
|
|
log_info "Creating repo '$dn' at $repo ..."
|
|
kp_d repository create filesystem --path="$repo" \
|
|
--cache-directory=/var/cache/kopia-backup \
|
|
|| { log_error "Failed to create '$dn' repo."; return 1; }
|
|
fi
|
|
|
|
kp_d policy set --global --compression=zstd \
|
|
--keep-latest="$KEEP_LATEST" \
|
|
--keep-daily=7 --keep-weekly=4 --keep-monthly=3 \
|
|
--keep-annual=0 --keep-hourly=0 >/dev/null
|
|
log_success "Repo '$dn' ready at $repo"
|
|
done
|
|
unset -f kp_d
|
|
|
|
# ── 7b. Offsite mirror (Backblaze B2) ────────────────────────────────────
|
|
# Kopia's dedicated "b2" sync-to provider is marked [DEPRECATED] in
|
|
# Kopia's own docs (kopia.io/docs/reference/command-line/common/
|
|
# repository-sync-to-b2/) — confirmed before writing this rather than
|
|
# building on a command that's on its way out. B2's S3-compatible
|
|
# endpoint plus the actively-maintained `sync-to s3` provider is the
|
|
# supported path instead: same B2 application key, just pointed at
|
|
# B2's own s3.<region>.backblazeb2.com endpoint instead of AWS.
|
|
#
|
|
# Bucket creation and the application key can't be automated here on
|
|
# purpose — Object Lock in particular is a deliberate, one-time choice
|
|
# B2 only lets you make at bucket creation, not something safe for a
|
|
# script to flip on (or skip) silently on someone's behalf. This walks
|
|
# through both console steps, then handles the mechanical part: taking
|
|
# the resulting bucket/endpoint/key and writing a verified
|
|
# REMOTE_TYPE/REMOTE_ARGS into backup.conf.
|
|
#
|
|
# Encryption is NOT a separate step here — Kopia already encrypts
|
|
# everything client-side (AES-256-GCM) using the repository password
|
|
# set above, before any of it leaves this box. B2's own optional
|
|
# Server-Side Encryption toggle is redundant on top of that; harmless
|
|
# to also enable for defense-in-depth, but nothing here depends on it.
|
|
local REMOTE_TYPE="none" REMOTE_ARGS=""
|
|
if [ -f "$CONF_FILE" ]; then
|
|
# Preserve whatever's already configured if this is a re-run and
|
|
# the operator doesn't re-answer the prompt below — re-running this
|
|
# installer has no update/fresh distinction, so without this an
|
|
# already-working offsite mirror would silently reset to "none".
|
|
REMOTE_TYPE="$(grep '^REMOTE_TYPE=' "$CONF_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
|
REMOTE_ARGS="$(grep '^REMOTE_ARGS=' "$CONF_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
|
[ -z "$REMOTE_TYPE" ] && REMOTE_TYPE="none"
|
|
fi
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " OFFSITE MIRROR (optional)"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Mirrors every local repo above to Backblaze B2 after each backup run —"
|
|
echo " the actual '1 copy offsite' piece of a real 3-2-1 backup. Skip this if"
|
|
echo " you don't have a B2 account yet, or would rather set REMOTE_TYPE/"
|
|
echo " REMOTE_ARGS in backup.conf by hand later."
|
|
if [ "$REMOTE_TYPE" != "none" ]; then
|
|
echo ""
|
|
echo " Offsite mirroring is already configured (REMOTE_TYPE=$REMOTE_TYPE)."
|
|
echo " Answering yes below replaces it; answering no leaves it as-is."
|
|
fi
|
|
echo ""
|
|
local _setup_b2=""
|
|
prompt_yn " Set up Backblaze B2 offsite mirroring now? (y/N):" "n" _setup_b2
|
|
if [[ "$_setup_b2" =~ ^[Yy]$ ]]; then
|
|
echo ""
|
|
echo " Two one-time steps in the B2 web console first — this script can't do"
|
|
echo " these for you:"
|
|
echo ""
|
|
echo " 1) Buckets → Create a Bucket"
|
|
echo " - Files in Bucket: Private"
|
|
echo " - Object Lock: your call. ON means backups in this bucket can't be"
|
|
echo " deleted or overwritten for a retention period you choose, even by"
|
|
echo " someone holding valid credentials for it — protects the offsite"
|
|
echo " copy if this box is ever compromised, at the cost of genuinely not"
|
|
echo " being able to delete early yourself either. Can only be set at"
|
|
echo " bucket creation, not turned on later."
|
|
echo " - Note the endpoint shown on the bucket's details page afterward,"
|
|
echo " e.g. s3.us-west-004.backblazeb2.com — you'll need it below."
|
|
echo ""
|
|
echo " 2) Account → App Keys → Add a New Application Key"
|
|
echo " - Allow access to: All — confirmed live (kopia/kopia issue #5329): a key"
|
|
echo " restricted to one bucket via this basic form doesn't get the"
|
|
echo " 'listBuckets' capability Kopia needs even though it only ever touches"
|
|
echo " that one bucket, and B2 fails the connection with an unhelpful 'Cannot"
|
|
echo " access bucket' error. Restricting to one bucket only works if you add"
|
|
echo " listBuckets via the B2 CLI/API's own key-creation call instead of this"
|
|
echo " form — not something this walkthrough covers."
|
|
echo " - Type: Read and Write"
|
|
echo " - B2 shows the application key ONLY once — copy both values now,"
|
|
echo " you can't retrieve the key itself again afterward."
|
|
echo ""
|
|
# Character counts are echoed after each field (never the value
|
|
# itself for the hidden one) so a failed paste is visible
|
|
# immediately instead of only surfacing as a generic "left blank"
|
|
# warning after all four prompts have already gone by — confirmed
|
|
# live: a paste into the hidden Application Key field can silently
|
|
# capture nothing depending on the terminal/SSH client, with no
|
|
# other symptom until this point.
|
|
# Pre-fill from whatever's already configured (only meaningful if the
|
|
# existing REMOTE_TYPE really is s3/B2 — a REMOTE_ARGS left over from
|
|
# a different provider, e.g. sftp, wouldn't parse into anything
|
|
# useful here and is harmlessly skipped). Otherwise reconfiguring
|
|
# just to rotate one field means blindly retyping all four, and a
|
|
# mispaste on any one of them loses the other three that were
|
|
# already typed correctly this run.
|
|
local _existing_b2_bucket="" _existing_b2_endpoint="" _existing_b2_keyid="" _existing_b2_appkey=""
|
|
if [ "$REMOTE_TYPE" = "s3" ]; then
|
|
_existing_b2_bucket="$(echo "$REMOTE_ARGS" | grep -oE -- '--bucket=[^ ]*' | cut -d= -f2-)"
|
|
_existing_b2_endpoint="$(echo "$REMOTE_ARGS" | grep -oE -- '--endpoint=[^ ]*' | cut -d= -f2-)"
|
|
_existing_b2_keyid="$(echo "$REMOTE_ARGS" | grep -oE -- '--access-key=[^ ]*' | cut -d= -f2-)"
|
|
_existing_b2_appkey="$(echo "$REMOTE_ARGS" | grep -oE -- '--secret-access-key=[^ ]*' | cut -d= -f2-)"
|
|
fi
|
|
local B2_BUCKET="$_existing_b2_bucket" B2_ENDPOINT="$_existing_b2_endpoint" B2_KEY_ID="$_existing_b2_keyid" B2_APP_KEY=""
|
|
prompt_text " Bucket name:" "$B2_BUCKET" B2_BUCKET
|
|
echo " (${#B2_BUCKET} characters entered)"
|
|
prompt_text " Endpoint (e.g. s3.us-west-004.backblazeb2.com):" "$B2_ENDPOINT" B2_ENDPOINT
|
|
echo " (${#B2_ENDPOINT} characters entered)"
|
|
prompt_text " Application Key ID:" "$B2_KEY_ID" B2_KEY_ID
|
|
echo " (${#B2_KEY_ID} characters entered)"
|
|
read -rsp " Application Key (input hidden${_existing_b2_appkey:+ — leave blank to keep the existing one}): " B2_APP_KEY; echo
|
|
[ -z "$B2_APP_KEY" ] && B2_APP_KEY="$_existing_b2_appkey"
|
|
echo " (${#B2_APP_KEY} characters entered)"
|
|
|
|
local _B2_MISSING=""
|
|
[ -z "$B2_BUCKET" ] && _B2_MISSING="${_B2_MISSING}Bucket name, "
|
|
[ -z "$B2_ENDPOINT" ] && _B2_MISSING="${_B2_MISSING}Endpoint, "
|
|
[ -z "$B2_KEY_ID" ] && _B2_MISSING="${_B2_MISSING}Application Key ID, "
|
|
[ -z "$B2_APP_KEY" ] && _B2_MISSING="${_B2_MISSING}Application Key, "
|
|
if [ -n "$_B2_MISSING" ]; then
|
|
log_warning "Left blank: ${_B2_MISSING%, } — skipping B2 setup this run."
|
|
else
|
|
log_info "Verifying B2 credentials (dry-run sync against the 'default' repo)..."
|
|
local _b2_err
|
|
if _b2_err="$(env KOPIA_PASSWORD="${DEST_PASSWORDS[default]}" "$KOPIA_BIN" \
|
|
--config-file="${DEST_CONFIGS[default]}" repository sync-to s3 \
|
|
--bucket="$B2_BUCKET" --access-key="$B2_KEY_ID" \
|
|
--secret-access-key="$B2_APP_KEY" --endpoint="$B2_ENDPOINT" \
|
|
--dry-run 2>&1)"; then
|
|
REMOTE_TYPE="s3"
|
|
REMOTE_ARGS="--bucket=$B2_BUCKET --access-key=$B2_KEY_ID --secret-access-key=$B2_APP_KEY --endpoint=$B2_ENDPOINT"
|
|
log_success "B2 credentials verified — offsite mirroring will run after each backup."
|
|
else
|
|
# All four fields were non-empty (the blank-field check above
|
|
# already ruled that out) — this is B2 rejecting what was
|
|
# entered, not missing input. Echoing back what was actually
|
|
# used (never the secret) so it's easy to eyeball against
|
|
# B2's own confirmation screen — the most common cause here
|
|
# is pairing the Key ID from one Application Key with the
|
|
# Secret from a different one (e.g. after creating more than
|
|
# one while troubleshooting).
|
|
log_warning "B2 rejected these credentials — bucket, endpoint, or key mismatch"
|
|
log_warning "(not blank input — all four fields were entered):"
|
|
log_warning " Bucket: $B2_BUCKET"
|
|
log_warning " Endpoint: $B2_ENDPOINT"
|
|
log_warning " Application Key ID: $B2_KEY_ID"
|
|
log_warning "Common cause: the Key ID and Application Key are from two DIFFERENT"
|
|
log_warning "keys (easy to mix up if you created more than one). Re-check both"
|
|
log_warning "values come from the SAME entry on B2's App Keys page."
|
|
log_warning "Raw error from B2/Kopia:"
|
|
log_warning "$_b2_err"
|
|
log_warning "Not enabling offsite mirroring this run. Re-run this installer once"
|
|
log_warning "fixed, or hand-edit REMOTE_TYPE/REMOTE_ARGS in backup.conf directly."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ── 7c. Additional mirror — direct SFTP to another box (optional) ────────
|
|
# REMOTE_TYPE/REMOTE_ARGS above is ONE offsite mirror. This adds any
|
|
# number of FURTHER ones that all run after every backup too — e.g.
|
|
# Backblaze B2 AND a spare box reachable over Tailscale/wg-easy/Netbird,
|
|
# simultaneously, not one instead of the other. Stored as its own list
|
|
# (EXTRA_MIRROR_NAMES + MIRROR_<name>_TYPE/_ARGS per entry) so it's
|
|
# additive on top of the existing single-mirror REMOTE_TYPE mechanism
|
|
# rather than replacing it — an existing B2-only backup.conf keeps
|
|
# working unchanged if this section is skipped.
|
|
local EXTRA_MIRROR_NAMES=""
|
|
declare -A EXTRA_MIRROR_TYPE=() EXTRA_MIRROR_ARGS=()
|
|
if [ -f "$CONF_FILE" ]; then
|
|
EXTRA_MIRROR_NAMES="$(grep '^EXTRA_MIRROR_NAMES=' "$CONF_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
|
local _emn
|
|
for _emn in $EXTRA_MIRROR_NAMES; do
|
|
EXTRA_MIRROR_TYPE["$_emn"]="$(grep "^MIRROR_${_emn}_TYPE=" "$CONF_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
|
EXTRA_MIRROR_ARGS["$_emn"]="$(grep "^MIRROR_${_emn}_ARGS=" "$CONF_FILE" 2>/dev/null | cut -d= -f2- | sed -E 's/^"(.*)"$/\1/')"
|
|
done
|
|
fi
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " ADDITIONAL MIRROR — direct to another box (optional)"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Mirrors to a SECOND destination over SSH/SFTP, in addition to (not"
|
|
echo " instead of) the offsite mirror above."
|
|
if [ -n "$EXTRA_MIRROR_NAMES" ]; then
|
|
echo ""
|
|
echo " Already configured: $EXTRA_MIRROR_NAMES — kept either way; answering"
|
|
echo " yes below only adds another one, it doesn't replace these."
|
|
fi
|
|
echo ""
|
|
local _ADD_SFTP_MIRROR=""
|
|
local _sftp_default_host="${DR_SYNC_HOST:-}"
|
|
prompt_yn " Add a direct mirror to another box$( [ -n "$_sftp_default_host" ] && echo " (e.g. $_sftp_default_host, same as the DR-spare above)")? (y/n):" "n" _ADD_SFTP_MIRROR
|
|
if [[ "$_ADD_SFTP_MIRROR" =~ ^[Yy]$ ]]; then
|
|
echo ""
|
|
echo " 1) SFTP — Kopia's own SFTP backend, syncs straight to a directory"
|
|
echo " 2) S3 (Garage) — a Garage instance already running on that box"
|
|
echo " (services/garage.sh) — reuses Kopia's S3 backend, the same one"
|
|
echo " already proven reliable for the Backblaze B2 mirror above,"
|
|
echo " instead of Kopia's less-exercised SFTP backend"
|
|
echo ""
|
|
local _MIRROR_TYPE_CHOICE=""
|
|
prompt_text " Mirror type [1]:" "1" _MIRROR_TYPE_CHOICE
|
|
_MIRROR_TYPE_CHOICE="${_MIRROR_TYPE_CHOICE:-1}"
|
|
|
|
local _SFTP_DEST=""
|
|
prompt_text " SSH destination, user@host (~/.ssh/config aliases work too):" "$_sftp_default_host" _SFTP_DEST
|
|
if [ -z "$_SFTP_DEST" ]; then
|
|
log_warning " No destination entered — skipping this mirror."
|
|
else
|
|
# Resolve through `ssh -G` rather than a plain @-split, so an
|
|
# ~/.ssh/config alias (e.g. from wg-easy's sync-ssh-aliases.sh)
|
|
# works here too. Kopia's sync-to sftp has its own SFTP client
|
|
# and does not read ~/.ssh/config itself — --host has to be the
|
|
# real hostname/IP either way, so this resolves it once here
|
|
# instead of failing later with the alias name as a literal,
|
|
# unresolvable hostname. Falls back to the plain @-split if
|
|
# `ssh -G` can't resolve it (e.g. no matching Host block).
|
|
local _ssh_g _SFTP_USER _SFTP_HOSTNAME _SFTP_PORT
|
|
_ssh_g="$(ssh -G "$_SFTP_DEST" 2>/dev/null)"
|
|
_SFTP_USER="$(echo "$_ssh_g" | awk '/^user /{print $2; exit}')"
|
|
_SFTP_HOSTNAME="$(echo "$_ssh_g" | awk '/^hostname /{print $2; exit}')"
|
|
_SFTP_PORT="$(echo "$_ssh_g" | awk '/^port /{print $2; exit}')"
|
|
[ -z "$_SFTP_USER" ] && _SFTP_USER="${_SFTP_DEST%%@*}"
|
|
[ -z "$_SFTP_HOSTNAME" ] && _SFTP_HOSTNAME="${_SFTP_DEST#*@}"
|
|
# Kopia's sftp storage defaults to port 22 same as ssh(1) would,
|
|
# but it never reads ~/.ssh/config to get there — a `Port` line
|
|
# in a Host block (the common case for anything reachable from
|
|
# the internet, to cut down on scanner noise) is exactly the
|
|
# kind of thing that silently vanishes without this. Confirmed
|
|
# live: this produced "server unexpectedly closed connection:
|
|
# unexpected EOF" — Kopia connecting to the real host on the
|
|
# WRONG port, not a credentials or host-key problem, while plain
|
|
# `ssh main` kept working fine throughout since it reads that
|
|
# same Port line correctly.
|
|
_SFTP_PORT="${_SFTP_PORT:-22}"
|
|
log_info " Using ${_SFTP_USER}@${_SFTP_HOSTNAME}:${_SFTP_PORT} for this mirror (resolved via ~/.ssh/config)."
|
|
|
|
local _MIRROR_NAME=""
|
|
prompt_text " Short name for this mirror (letters/numbers/underscores):" "$( [ "$_MIRROR_TYPE_CHOICE" = "2" ] && echo garage || echo spare )" _MIRROR_NAME
|
|
_MIRROR_NAME="${_MIRROR_NAME:-$( [ "$_MIRROR_TYPE_CHOICE" = "2" ] && echo garage || echo spare )}"
|
|
_MIRROR_NAME="${_MIRROR_NAME//[^a-zA-Z0-9_]/_}"
|
|
|
|
# Both types need at least a working passwordless SSH connection
|
|
# to this box — SFTP because Kopia authenticates with a keyfile
|
|
# over that same connection; S3/Garage because that's how this
|
|
# script reads Garage's own credentials remotely (the actual S3
|
|
# dry-run below talks straight to Garage's S3 API port, no SSH
|
|
# involved in that part at all).
|
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$_SFTP_DEST" true 2>/dev/null; then
|
|
log_warning " Couldn't SSH to $_SFTP_DEST without a password — not adding this"
|
|
log_warning " mirror until that works: ssh-copy-id $_SFTP_DEST"
|
|
elif [ "$_MIRROR_TYPE_CHOICE" = "2" ]; then
|
|
# ── S3 (Garage) ──────────────────────────────────────────────
|
|
# Read the real bucket/key/port straight from the remote
|
|
# instance's own .env rather than asking the operator to
|
|
# retype them here — those values are generated once by
|
|
# services/garage.sh and never touched again on its own
|
|
# Update runs, so this is always reading the box's actual
|
|
# current configuration, not something baked in here.
|
|
local _garage_env
|
|
_garage_env="$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_SFTP_DEST" "cat ~/docker/garage/.env 2>/dev/null")"
|
|
if [ -z "$_garage_env" ]; then
|
|
log_warning " Garage isn't installed on $_SFTP_DEST yet — not adding this mirror."
|
|
log_warning " Install it there first, then re-run this installer:"
|
|
log_warning " sudo ./setup.sh garage (or: sudo bash garage.sh, on a box without this repo)"
|
|
else
|
|
local _g_bucket _g_key_id _g_key_secret _g_port
|
|
_g_bucket="$(echo "$_garage_env" | sed -nE "s/^GARAGE_BUCKET='?([^']*)'?\$/\1/p")"
|
|
_g_key_id="$(echo "$_garage_env" | sed -nE "s/^GARAGE_ACCESS_KEY_ID='?([^']*)'?\$/\1/p")"
|
|
_g_key_secret="$(echo "$_garage_env" | sed -nE "s/^GARAGE_ACCESS_KEY_SECRET='?([^']*)'?\$/\1/p")"
|
|
_g_port="$(echo "$_garage_env" | sed -nE "s/^GARAGE_S3_API_PORT=([0-9]+)\$/\1/p")"
|
|
if [ -z "$_g_bucket" ] || [ -z "$_g_key_id" ] || [ -z "$_g_key_secret" ] || [ -z "$_g_port" ]; then
|
|
log_warning " Garage is installed on $_SFTP_DEST but its .env is missing something"
|
|
log_warning " expected — check ~/docker/garage/.env there. Not adding this mirror."
|
|
else
|
|
local _g_endpoint="${_SFTP_HOSTNAME}:${_g_port}"
|
|
log_info "Verifying S3 (Garage) mirror at ${_g_endpoint}, bucket '$_g_bucket' (dry-run)..."
|
|
local _s3_err
|
|
if _s3_err="$(env KOPIA_PASSWORD="${DEST_PASSWORDS[default]}" "$KOPIA_BIN" \
|
|
--config-file="${DEST_CONFIGS[default]}" repository sync-to s3 \
|
|
--bucket="$_g_bucket" --endpoint="$_g_endpoint" \
|
|
--access-key="$_g_key_id" --secret-access-key="$_g_key_secret" \
|
|
--disable-tls --dry-run 2>&1)"; then
|
|
EXTRA_MIRROR_TYPE["$_MIRROR_NAME"]="s3"
|
|
EXTRA_MIRROR_ARGS["$_MIRROR_NAME"]="--bucket=$_g_bucket --endpoint=$_g_endpoint --access-key=$_g_key_id --secret-access-key=$_g_key_secret --disable-tls"
|
|
if [[ " $EXTRA_MIRROR_NAMES " != *" $_MIRROR_NAME "* ]]; then
|
|
if [ -z "$EXTRA_MIRROR_NAMES" ]; then
|
|
EXTRA_MIRROR_NAMES="$_MIRROR_NAME"
|
|
else
|
|
EXTRA_MIRROR_NAMES="$EXTRA_MIRROR_NAMES $_MIRROR_NAME"
|
|
fi
|
|
fi
|
|
log_success " S3 (Garage) mirror '$_MIRROR_NAME' verified — will run after every backup."
|
|
else
|
|
log_warning " S3 (Garage) dry-run failed — not adding this mirror:"
|
|
log_warning " $_s3_err"
|
|
fi
|
|
fi
|
|
fi
|
|
else
|
|
# ── SFTP ─────────────────────────────────────────────────────
|
|
# Suggest a subdirectory of the DR-spare's own path (if one is
|
|
# configured) rather than an unrelated default — reusing the
|
|
# same spare location the operator already picked, but in its
|
|
# own /kopia-data subdirectory so the actual repository data
|
|
# (Kopia's own blob-store files) doesn't end up visually mixed
|
|
# in with the two plain config files the DR-spare sync writes
|
|
# directly into DR_SYNC_PATH itself.
|
|
local _SFTP_PATH_DEFAULT="~/backups/kopia-mirror"
|
|
[ -n "${DR_SYNC_PATH:-}" ] && _SFTP_PATH_DEFAULT="${DR_SYNC_PATH%/}/kopia-data"
|
|
local _SFTP_PATH=""
|
|
prompt_text " Remote path for the repo:" "$_SFTP_PATH_DEFAULT" _SFTP_PATH
|
|
_SFTP_PATH="${_SFTP_PATH:-$_SFTP_PATH_DEFAULT}"
|
|
|
|
# sync-to sftp doesn't shell out to the system ssh client, so
|
|
# it needs an explicit key/known_hosts file rather than
|
|
# picking up whatever plain `ssh` already trusts automatically.
|
|
_backup_ensure_root_ssh_key "$_SFTP_DEST"
|
|
local _SFTP_KEYFILE="$_ROOT_SSH_KEYFILE"
|
|
|
|
if [ -z "$_SFTP_KEYFILE" ]; then
|
|
log_warning " No SSH key available for root — can't add this mirror."
|
|
else
|
|
log_info "Verifying SFTP mirror (dry-run sync against the 'default' repo)..."
|
|
local _sftp_err
|
|
if _sftp_err="$(env KOPIA_PASSWORD="${DEST_PASSWORDS[default]}" "$KOPIA_BIN" \
|
|
--config-file="${DEST_CONFIGS[default]}" repository sync-to sftp \
|
|
--host="$_SFTP_HOSTNAME" --port="$_SFTP_PORT" --username="$_SFTP_USER" --path="$_SFTP_PATH" \
|
|
--keyfile="$_SFTP_KEYFILE" --known-hosts=/root/.ssh/known_hosts \
|
|
--dry-run 2>&1)"; then
|
|
EXTRA_MIRROR_TYPE["$_MIRROR_NAME"]="sftp"
|
|
EXTRA_MIRROR_ARGS["$_MIRROR_NAME"]="--host=$_SFTP_HOSTNAME --port=$_SFTP_PORT --username=$_SFTP_USER --path=$_SFTP_PATH --keyfile=$_SFTP_KEYFILE --known-hosts=/root/.ssh/known_hosts"
|
|
# Reusing an existing mirror name reconfigures it (the
|
|
# associative-array assignments above already do that)
|
|
# without duplicating it in the space-separated name list.
|
|
if [[ " $EXTRA_MIRROR_NAMES " != *" $_MIRROR_NAME "* ]]; then
|
|
if [ -z "$EXTRA_MIRROR_NAMES" ]; then
|
|
EXTRA_MIRROR_NAMES="$_MIRROR_NAME"
|
|
else
|
|
EXTRA_MIRROR_NAMES="$EXTRA_MIRROR_NAMES $_MIRROR_NAME"
|
|
fi
|
|
fi
|
|
log_success " SFTP mirror '$_MIRROR_NAME' verified — will run after every backup."
|
|
else
|
|
log_warning " SFTP dry-run failed — not adding this mirror:"
|
|
log_warning " $_sftp_err"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ── 8. Write backup.conf ─────────────────────────────────────────────────
|
|
log_info "Writing $CONF_FILE ..."
|
|
{
|
|
echo "# ── backup.conf ────────────────────────────────────────────────────────────"
|
|
echo "# Generated $(date '+%F %T'). Safe to hand-edit."
|
|
echo "# Worker : sudo $WORKER"
|
|
echo "# Restore: sudo $RESTORE"
|
|
echo ""
|
|
echo "KOPIA=\"$KOPIA_BIN\""
|
|
echo ""
|
|
echo "# Space-separated list of destination names (defines iteration order)."
|
|
echo "DEST_NAMES=\"${DEST_NAMES_ARR[*]}\""
|
|
echo "DEST_DEFAULT=\"default\""
|
|
echo ""
|
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
|
echo "# ── destination: $dn"
|
|
echo "DEST_${dn}_REPO=\"${DEST_REPOS[$dn]}\""
|
|
echo "DEST_${dn}_CONFIG=\"${DEST_CONFIGS[$dn]}\""
|
|
printf "DEST_%s_PASSWORD='%s'\n" "$dn" "${DEST_PASSWORDS[$dn]}"
|
|
echo ""
|
|
done
|
|
echo "# ── Service → destination map ───────────────────────────────────────────────"
|
|
echo "# Format: SVC_<name>=<dest_name> (hyphens in service names become underscores)"
|
|
echo "# Omit a service (or comment it out) to use DEST_DEFAULT."
|
|
for svc in "${ALL_SVCS[@]}"; do
|
|
local svc_var="${svc//-/_}"
|
|
local dest_val="${SVC_DEST_MAP[$svc]:-}"
|
|
if [ -n "$dest_val" ]; then
|
|
echo "SVC_${svc_var}=\"${dest_val}\""
|
|
else
|
|
echo "# SVC_${svc_var}=\"default\""
|
|
fi
|
|
done
|
|
echo ""
|
|
echo "# ── Optional offsite mirror ─────────────────────────────────────────────────"
|
|
echo "# Mirror ALL repos offsite after each run (see kopia repository sync-to --help)."
|
|
echo "# B2: use the s3 provider against B2's S3-compatible endpoint, not the b2"
|
|
echo "# provider — kopia.io marks repository-sync-to-b2 as deprecated. Example:"
|
|
echo "# REMOTE_TYPE=s3 REMOTE_ARGS=\"--bucket=NAME --access-key=KEYID --secret-access-key=KEY --endpoint=s3.us-west-004.backblazeb2.com\""
|
|
echo "# Example SFTP: REMOTE_TYPE=sftp REMOTE_ARGS=\"--host H --username U --path /srv/...\""
|
|
echo "REMOTE_TYPE=\"$REMOTE_TYPE\""
|
|
echo "REMOTE_ARGS=\"$REMOTE_ARGS\""
|
|
echo ""
|
|
echo "# ── Additional mirrors — run alongside REMOTE_TYPE above, not instead of it ──"
|
|
echo "# Space-separated list of names; each gets its own MIRROR_<name>_TYPE/_ARGS,"
|
|
echo "# same argument shape as REMOTE_ARGS. Every one of these runs after every"
|
|
echo "# backup too, in addition to the REMOTE_TYPE mirror above."
|
|
echo "EXTRA_MIRROR_NAMES=\"$EXTRA_MIRROR_NAMES\""
|
|
for _emn in $EXTRA_MIRROR_NAMES; do
|
|
echo "MIRROR_${_emn}_TYPE=\"${EXTRA_MIRROR_TYPE[$_emn]}\""
|
|
echo "MIRROR_${_emn}_ARGS=\"${EXTRA_MIRROR_ARGS[$_emn]}\""
|
|
done
|
|
echo ""
|
|
echo "# ── Notifications (ntfy) ─────────────────────────────────────────────────────"
|
|
echo "# Set NTFY_URL to receive backup success/failure alerts."
|
|
echo "# Leave blank to disable. NTFY_TOKEN is optional (for private topics)."
|
|
printf "NTFY_URL='%s'\n" "${NTFY_URL:-}"
|
|
printf "NTFY_TOKEN='%s'\n" "${NTFY_TOKEN:-}"
|
|
echo ""
|
|
echo "# ── Disaster-recovery spare sync ───────────────────────────────────────────"
|
|
echo "# If set, backup_kopia.sh scp's this backup.conf + README.md to"
|
|
echo "# DR_SYNC_HOST:DR_SYNC_PATH after every successful backup, so a spare box"
|
|
echo "# running dr_bringup.sh is always ready with no manual copy step. Requires"
|
|
echo "# passwordless SSH from this box to the spare (see README.md)."
|
|
printf "DR_SYNC_HOST='%s'\n" "${DR_SYNC_HOST:-}"
|
|
printf "DR_SYNC_PATH='%s'\n" "${DR_SYNC_PATH:-~/docker/backup}"
|
|
} > "$CONF_FILE"
|
|
chown root:root "$CONF_FILE" 2>/dev/null || true
|
|
chmod 600 "$CONF_FILE"
|
|
log_success "backup.conf written (chmod 600)"
|
|
|
|
# ── 9. Install worker script ──────────────────────────────────────────────
|
|
log_info "Installing worker $WORKER ..."
|
|
cp "${HERE:-}/extras/backup_kopia.sh" "$WORKER"
|
|
chmod +x "$WORKER"
|
|
chown root:root "$WORKER" 2>/dev/null || true
|
|
log_success "backup_kopia.sh installed"
|
|
|
|
# ── 10. Install restore script ────────────────────────────────────────────
|
|
local RESTORE_SRC="${HERE:-}/extras/restore_kopia.sh"
|
|
if [ -f "$RESTORE_SRC" ]; then
|
|
cp "$RESTORE_SRC" "$RESTORE"
|
|
chmod +x "$RESTORE"
|
|
chown root:root "$RESTORE" 2>/dev/null || true
|
|
log_success "restore_kopia.sh installed"
|
|
else
|
|
log_warning "extras/restore_kopia.sh not found — restore script not installed"
|
|
log_warning "Copy it manually: cp extras/restore_kopia.sh $RESTORE"
|
|
fi
|
|
|
|
# ── 10b. Install disaster-recovery bring-up script ────────────────────────
|
|
# Non-interactive counterpart to restore_kopia.sh: restores every service's
|
|
# latest snapshot and runs `docker compose up -d` with no prompts, meant to
|
|
# run on a cold spare box during a real outage rather than the primary.
|
|
local DR_BRINGUP_SRC="${HERE:-}/extras/dr_bringup_kopia.sh"
|
|
if [ -f "$DR_BRINGUP_SRC" ]; then
|
|
cp "$DR_BRINGUP_SRC" "$DR_BRINGUP"
|
|
chmod +x "$DR_BRINGUP"
|
|
chown root:root "$DR_BRINGUP" 2>/dev/null || true
|
|
log_success "dr_bringup.sh installed"
|
|
else
|
|
log_warning "extras/dr_bringup_kopia.sh not found — DR bring-up script not installed"
|
|
log_warning "Copy it manually: cp extras/dr_bringup_kopia.sh $DR_BRINGUP"
|
|
fi
|
|
|
|
# ── 11. Install test scripts ─────────────────────────────────────────────
|
|
local TEST_SCRIPT="$DIR/test_backup_kopia.sh"
|
|
local TEST_SRC="${HERE:-}/extras/test_backup_kopia.sh"
|
|
if [ -f "$TEST_SRC" ]; then
|
|
cp "$TEST_SRC" "$TEST_SCRIPT"
|
|
chmod +x "$TEST_SCRIPT"
|
|
chown root:root "$TEST_SCRIPT" 2>/dev/null || true
|
|
log_success "test_backup_kopia.sh installed"
|
|
else
|
|
log_warning "extras/test_backup_kopia.sh not found — test script not installed"
|
|
fi
|
|
|
|
local TEST_UNIFIED="$DIR/test_backup.sh"
|
|
local TEST_UNIFIED_SRC="${HERE:-}/extras/test_backup.sh"
|
|
if [ -f "$TEST_UNIFIED_SRC" ]; then
|
|
cp "$TEST_UNIFIED_SRC" "$TEST_UNIFIED"
|
|
chmod +x "$TEST_UNIFIED"
|
|
chown root:root "$TEST_UNIFIED" 2>/dev/null || true
|
|
log_success "test_backup.sh installed"
|
|
fi
|
|
|
|
# ── 11b. Backup test timer ───────────────────────────────────────────────
|
|
# Every service in this test stops briefly (seconds) while its data gets
|
|
# moved aside and restored back — same interruption profile as the main
|
|
# backup job itself. Weekly is the most thorough default, but that's a
|
|
# standing tradeoff against a weekly blip on every service; offer the
|
|
# same Daily/Weekly/Monthly/Custom shape the main backup schedule above
|
|
# already gives, rather than hardcoding one choice.
|
|
local TEST_SVC_NAME="post-install-backup-test"
|
|
local _add_test=""
|
|
prompt_yn " Schedule an automated backup restore test? (y/N):" "n" _add_test
|
|
if [[ "$_add_test" =~ ^[Yy]$ ]]; then
|
|
echo ""
|
|
echo " 1) Weekly (Saturday 03:00) (recommended)"
|
|
echo " 2) Monthly (1st of the month, 03:00)"
|
|
echo " 3) Custom (systemd OnCalendar)"
|
|
echo ""
|
|
local _test_sch=""
|
|
prompt_text " How often? [1]:" "1" _test_sch
|
|
local TEST_ONCALENDAR TEST_SCHED_LABEL TEST_CRON=""
|
|
case "${_test_sch:-1}" in
|
|
2) TEST_ONCALENDAR="*-*-01 03:00:00"; TEST_SCHED_LABEL="monthly (1st, 03:00)"; TEST_CRON="0 3 1 * *" ;;
|
|
3) prompt_text " OnCalendar expression:" "Sat *-*-* 03:00:00" TEST_ONCALENDAR
|
|
TEST_SCHED_LABEL="$TEST_ONCALENDAR" ;;
|
|
*) TEST_ONCALENDAR="Sat *-*-* 03:00:00"; TEST_SCHED_LABEL="weekly (Saturday 03:00)"; TEST_CRON="0 3 * * 6" ;;
|
|
esac
|
|
|
|
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
|
tee "/etc/systemd/system/${TEST_SVC_NAME}.service" >/dev/null << SVCEOF
|
|
[Unit]
|
|
Description=Automated restore test for Kopia backup
|
|
After=docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/bin/bash $TEST_SCRIPT
|
|
SVCEOF
|
|
|
|
tee "/etc/systemd/system/${TEST_SVC_NAME}.timer" >/dev/null << SVCEOF
|
|
[Unit]
|
|
Description=Kopia backup restore test ($TEST_SCHED_LABEL)
|
|
|
|
[Timer]
|
|
OnCalendar=$TEST_ONCALENDAR
|
|
Persistent=true
|
|
RandomizedDelaySec=600
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
SVCEOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now "${TEST_SVC_NAME}.timer"
|
|
log_success "Backup test timer enabled ($TEST_SCHED_LABEL)"
|
|
else
|
|
if [ -z "$TEST_CRON" ]; then
|
|
log_warning "Custom OnCalendar schedules aren't auto-translated to cron — installing"
|
|
log_warning "a weekly placeholder; edit /etc/cron.d/${TEST_SVC_NAME} to adjust the timing."
|
|
TEST_CRON="0 3 * * 6"
|
|
fi
|
|
echo "$TEST_CRON root /bin/bash $TEST_SCRIPT >> /var/log/${TEST_SVC_NAME}.log 2>&1" \
|
|
> "/etc/cron.d/${TEST_SVC_NAME}"
|
|
log_success "Backup test cron installed ($TEST_SCHED_LABEL)"
|
|
fi
|
|
|
|
echo ""
|
|
local _run_now=""
|
|
prompt_yn " Run the first test now, instead of waiting for the schedule? (y/N):" "n" _run_now
|
|
if [[ "$_run_now" =~ ^[Yy]$ ]]; then
|
|
log_info "Running initial backup restore test..."
|
|
bash "$TEST_SCRIPT" || log_warning "Initial test reported failures — see the output above and /var/log/post-install-backup-test.log."
|
|
fi
|
|
fi
|
|
|
|
# ── 12. Systemd timer ────────────────────────────────────────────────────
|
|
log_info "Installing systemd timer ($SCHED_LABEL)..."
|
|
local AUTORUN=""
|
|
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
|
tee "/etc/systemd/system/${SVC_NAME}.service" >/dev/null << SVCEOF
|
|
[Unit]
|
|
Description=Post-install backup (full Docker service directories via Kopia)
|
|
After=docker.service network-online.target
|
|
Wants=docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/bin/bash $WORKER run
|
|
SVCEOF
|
|
|
|
tee "/etc/systemd/system/${SVC_NAME}.timer" >/dev/null << SVCEOF
|
|
[Unit]
|
|
Description=Schedule post-install backup ($SCHED_LABEL)
|
|
|
|
[Timer]
|
|
OnCalendar=$ONCALENDAR
|
|
Persistent=true
|
|
RandomizedDelaySec=300
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
SVCEOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now "${SVC_NAME}.timer"
|
|
log_success "Timer enabled: $SCHED_LABEL"
|
|
AUTORUN="systemctl list-timers ${SVC_NAME}.timer"
|
|
else
|
|
log_warning "systemd not detected — installing cron fallback."
|
|
local CRON
|
|
case "${_sch:-1}" in
|
|
2) CRON="0 2,14 * * *" ;;
|
|
3) CRON="0 2 * * 0" ;;
|
|
*) CRON="0 2 * * *" ;;
|
|
esac
|
|
echo "$CRON root /bin/bash $WORKER run >> /var/log/${SVC_NAME}.log 2>&1" \
|
|
> "/etc/cron.d/${SVC_NAME}"
|
|
log_success "Cron job installed: $CRON"
|
|
AUTORUN="cat /etc/cron.d/${SVC_NAME}"
|
|
fi
|
|
|
|
# ── Write README ─────────────────────────────────────────────────────────
|
|
# Written before the optional first run below so, if DR sync is enabled,
|
|
# the very first backup already ships an up-to-date README to the spare.
|
|
local DEST_LIST_MD=""
|
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
|
DEST_LIST_MD+="- **${dn}**: ${DEST_REPOS[$dn]}"$'\n'
|
|
done
|
|
|
|
local DR_SYNC_MD OFFSITE_MD
|
|
if [ -n "${DR_SYNC_HOST:-}" ]; then
|
|
DR_SYNC_MD="Configured: after every successful backup, this box copies backup.conf + this README to \`${DR_SYNC_HOST}:${DR_SYNC_PATH:-~/docker/backup}\` over SSH."
|
|
else
|
|
DR_SYNC_MD="Not configured. Re-run this installer to set it up, or copy backup.conf to the spare manually whenever it changes."
|
|
fi
|
|
if [ "${REMOTE_TYPE:-none}" != "none" ]; then
|
|
OFFSITE_MD="Configured: every backup also runs \`kopia repository sync-to ${REMOTE_TYPE}\` to mirror the repo off this box."
|
|
else
|
|
OFFSITE_MD="Not configured. Set REMOTE_TYPE/REMOTE_ARGS in backup.conf (see the comment above them) to mirror the repo off this box."
|
|
fi
|
|
|
|
write_readme "$DIR" << MD
|
|
# Backup — Kopia
|
|
|
|
Full recovery for every Docker service under \`$DOCKER_DIR\`: each service's
|
|
entire directory (compose file, \`.env\`, config, data, databases) is
|
|
snapshotted with Kopia — deduplicated, compressed (zstd), and encrypted.
|
|
Databases are captured consistently (container stopped briefly, snapshotted,
|
|
restarted); Minecraft instead gets a live save-all flush, no downtime.
|
|
|
|
## Destinations
|
|
|
|
$DEST_LIST_MD
|
|
## Schedule
|
|
|
|
$SCHED_LABEL — keeps the latest $KEEP_LATEST snapshots (plus 7 daily / 4
|
|
weekly / 3 monthly).
|
|
|
|
## Commands
|
|
|
|
\`\`\`bash
|
|
sudo $WORKER # back up now
|
|
sudo $WORKER snapshots # list all snapshots
|
|
sudo $WORKER policy # show retention policy
|
|
\`\`\`
|
|
|
|
### Restore — interactive, one service at a time
|
|
|
|
\`\`\`bash
|
|
sudo $RESTORE
|
|
sudo $RESTORE --list
|
|
\`\`\`
|
|
|
|
### Disaster recovery — unattended, every service, for a cold spare box
|
|
|
|
\`\`\`bash
|
|
sudo $DR_BRINGUP # restore + start everything
|
|
sudo $DR_BRINGUP --list # list what's restorable
|
|
sudo $DR_BRINGUP --dry-run # preview, touch nothing
|
|
sudo $DR_BRINGUP --service NAME # just one service
|
|
\`\`\`
|
|
|
|
A single service failing to restore or start does not stop the rest of the
|
|
batch — it's logged and skipped so the run maximizes what actually comes
|
|
back up. The exit code is only non-zero if nothing came up at all.
|
|
|
|
**On the spare box**, \`dr_bringup.sh\` needs \`backup.conf\` from this
|
|
directory to connect to the repo — see the DR spare sync section below.
|
|
|
|
## Disaster-recovery spare sync
|
|
|
|
$DR_SYNC_MD
|
|
|
|
Requires passwordless SSH (key-based) from this box to the spare — since the
|
|
backup timer runs as root, generate/authorize a key for root:
|
|
\`ssh-keygen\`, then \`ssh-copy-id\` to the spare.
|
|
|
|
## Offsite mirror
|
|
|
|
$OFFSITE_MD
|
|
|
|
## Backup test — stop / restore / compare / restore-back
|
|
|
|
\`\`\`bash
|
|
sudo $TEST_SCRIPT # test most recent backup, all services
|
|
sudo $TEST_SCRIPT --list # list testable services
|
|
sudo $TEST_SCRIPT --service NAME
|
|
\`\`\`
|
|
|
|
## Files
|
|
|
|
- \`backup.conf\` — destinations, passwords, retention, DR-sync/offsite settings (chmod 600)
|
|
- \`backup_kopia.sh\` — the worker the systemd timer runs
|
|
- \`restore_kopia.sh\` — interactive restore
|
|
- \`dr_bringup.sh\` — unattended full-stack restore + start
|
|
- \`test_backup_kopia.sh\` / \`test_backup.sh\` — automated restore tests
|
|
|
|
**Save the passwords in \`backup.conf\` somewhere safe** — without them the
|
|
encrypted repos cannot be restored.
|
|
MD
|
|
|
|
# ── 12. Optional first run ────────────────────────────────────────────────
|
|
echo ""
|
|
local _now=""
|
|
prompt_yn " Run the first backup now? (y/N):" "n" _now
|
|
if [[ "$_now" =~ ^[Yy]$ ]]; then
|
|
/bin/bash "$WORKER" run || log_warning "First backup reported warnings — check output above."
|
|
fi
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " BACKUP CONFIGURED"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Config : $CONF_FILE"
|
|
echo " Worker : $WORKER"
|
|
echo " Schedule : $SCHED_LABEL"
|
|
echo ""
|
|
echo " Destinations:"
|
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
|
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
|
done
|
|
echo ""
|
|
if [ "${#ALL_SVCS[@]}" -gt 0 ]; then
|
|
echo " Services backed up: ${ALL_SVCS[*]}"
|
|
else
|
|
echo " Services: none yet — auto-discovered on each run"
|
|
fi
|
|
echo ""
|
|
echo " Commands:"
|
|
echo " sudo $WORKER back up now"
|
|
echo " sudo $WORKER snapshots list all snapshots"
|
|
echo ""
|
|
echo " Restore (interactive, one service at a time):"
|
|
echo " sudo $RESTORE"
|
|
echo " sudo $RESTORE --list"
|
|
echo ""
|
|
echo " Disaster recovery (unattended, every service — for a cold spare box):"
|
|
echo " sudo $DR_BRINGUP restore + start everything"
|
|
echo " sudo $DR_BRINGUP --list list what's restorable"
|
|
echo " sudo $DR_BRINGUP --dry-run preview, touch nothing"
|
|
if [ -n "${DR_SYNC_HOST:-}" ]; then
|
|
echo " backup.conf + README.md sync to $DR_SYNC_HOST after every backup — the"
|
|
echo " spare stays ready with no manual copy step."
|
|
else
|
|
echo " Copy backup.conf to the spare box first — it holds the repo path(s)"
|
|
echo " and password(s) this needs to connect."
|
|
fi
|
|
echo ""
|
|
echo " Full docs: $DIR/README.md"
|
|
echo ""
|
|
echo " Backup test (stop/restore/compare/restore-back):"
|
|
echo " sudo $TEST_SCRIPT test most recent backup (all services)"
|
|
echo " sudo $TEST_SCRIPT --list list testable services"
|
|
echo " sudo $TEST_SCRIPT --service <n> test a specific service"
|
|
[ -n "${NTFY_URL:-}" ] && echo "" && echo " Notifications: $NTFY_URL"
|
|
echo ""
|
|
[ -n "$AUTORUN" ] && echo " $AUTORUN" && echo ""
|
|
log_warning "Save your passwords (in backup.conf) somewhere safe —"
|
|
log_warning "without them the encrypted repos cannot be restored."
|
|
echo ""
|
|
log_success "Backup configured."
|
|
}
|
|
|
|
# Run immediately when executed directly (deferred until after function definition)
|
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_backup
|