#!/bin/bash # SPDX-License-Identifier: GPL-3.0-or-later # zfs-replicate - Replicate ZFS datasets to TrueNAS # # Usage: # zfs-replicate # Replicate all configured datasets # zfs-replicate [dataset] # Replicate specific dataset set -e # TrueNAS Configuration # Try local network first, fall back to tailscale TRUENAS_LOCAL="truenas.local" TRUENAS_TAILSCALE="truenas" TRUENAS_USER="root" TRUENAS_POOL="vault" BACKUP_PATH="backups" # TODO: Configure actual path # Datasets to replicate DATASETS="zroot/ROOT/default zroot/home zroot/media zroot/vms" # Colors GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # Diagnostics go to stderr, never stdout. determine_host below runs inside a # command substitution, so anything these print on stdout would be captured # into TRUENAS_HOST instead of reaching the terminal or the journal -- which is # exactly how an unreachable TrueNAS used to abort this script with exit 1 and # no output at all. stderr also keeps the captured hostname clean. info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; } command -v syncoid >/dev/null 2>&1 || error "syncoid not found. Install sanoid package." # Determine which host to use determine_host() { if ping -c 1 -W 2 "$TRUENAS_LOCAL" &>/dev/null; then echo "$TRUENAS_LOCAL" elif ping -c 1 -W 2 "$TRUENAS_TAILSCALE" &>/dev/null; then echo "$TRUENAS_TAILSCALE" else error "Cannot reach TrueNAS at $TRUENAS_LOCAL or $TRUENAS_TAILSCALE" fi } TRUENAS_HOST=$(determine_host) info "Using TrueNAS host: $TRUENAS_HOST" # Single dataset mode if [[ -n "$1" ]]; then dataset="$1" dest="$TRUENAS_USER@$TRUENAS_HOST:$TRUENAS_POOL/$BACKUP_PATH/${dataset#zroot/}" info "Replicating $dataset -> $dest" syncoid --recursive "$dataset" "$dest" exit 0 fi # Full replication info "Starting ZFS replication to $TRUENAS_HOST" echo "" # A failed dataset must not stop the others -- but it must not be forgotten # either. This runs as a systemd oneshot on a nightly timer, so the exit code # is the only signal anyone sees; exiting 0 after every dataset failed made a # backup that had not run in months look identical to a working one. failed=0 for dataset in $DATASETS; do dest="$TRUENAS_USER@$TRUENAS_HOST:$TRUENAS_POOL/$BACKUP_PATH/${dataset#zroot/}" info "Replicating $dataset -> $dest" if syncoid --recursive "$dataset" "$dest"; then info " Success" else warn " Failed (will retry next run)" failed=$((failed + 1)) fi echo "" >&2 done if [ "$failed" -gt 0 ]; then warn "Replication complete with $failed of $(echo "$DATASETS" | wc -w) datasets failed." exit 1 fi info "Replication complete."