Shockbyte Backup Automation
What this does
Every night at 2AM: logs into the Shockbyte panel, downloads the latest JJAC_Survival_World backup, unzips it, prunes never-visited chunks, rsyncs the trimmed world to gmktec, and archives copies of the raw zip to a few dated locations — all unattended, even with the screen locked. No manual login needed under normal circumstances.
The problem: Cloudflare
panel.shockbyte.com and auth.shockbyte.com sit behind Cloudflare bot protection. AppleScript driving Safari (JS clicks, keystrokes, accessibility-tree clicks, coordinate clicks) was reliably blocked — every approach, every time.
Playwright driving a real, visible Chrome window (headless=False) gets through fine. Headless mode does not. Don't switch this back to headless — it will just start failing again.
Files
shockbyte_download_backup.py nightly downloader
shockbyte_auth.py shared Keychain + login-flow logic
shockbyte_login_setup_auto.py standalone forced session refresh
shockbyte_login_setup.py manual fallback login (by hand)
backup.sh unzip, dechunk, rsync, archive
com.david.shockbyte-backup.plist launchd schedule
One-time setup
python3 -m venv myvenv
source myvenv/bin/activate
pip3 install playwright psutil
playwright install chromium
psutil is used by shockbyte_download_backup.py to force-kill a stalled Chrome instance if a download hangs and the browser's own close() doesn't respond (see kill_marked_chrome() below). Optional but strongly recommended — without it, a stalled download just logs a warning and leaves the hung Chrome process running.
Store credentials in Keychain — not Passwords.app!
Keychain Access.app → File → New Password Item:
Name: Shockbyte
Account: <login email>
Password: <login password>
Note to self: Keychain Access.app and the newer Passwords.app store different item types. Only Keychain Access's "generic password" is readable via security find-generic-password — a Safari-saved login under the same name won't be found by the script.
The plaintext SHOCKBYTE_USERNAME constant (not secret, only the password comes from Keychain) now lives in shockbyte_auth.py, not shockbyte_login_setup_auto.py — that's the shared module both login scripts import, so it's the one to edit.
Get an initial session
python3 shockbyte_login_setup_auto.py
Approve the Keychain permission prompt with "Always Allow" so future unattended runs don't hang waiting on it.
Login form quirks (reverse-engineered by hand)
- Identifier-first flow: submit just the email, then a second page asks for the password.
- The email step's submit button starts disabled until their JS validates the field.
- The first click on it only shifts focus — confirmed by hand, not a Playwright bug — it takes a second click to actually submit.
Set up the LaunchAgent
mkdir -p ~/scripts/shockbyte_dl/logs
cp com.david.shockbyte-backup.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.david.shockbyte-backup.plist
launchctl enable gui/$(id -u)/com.david.shockbyte-backup
launchctl kickstart -k gui/$(id -u)/com.david.shockbyte-backup
Key detail: gui/<uid>, not system — runs inside the actual logged-in GUI session, which keeps working even with the screen locked (confirmed by testing). Sleep still kills it though — disable sleep in Energy settings.
Note to self: a bootstrap failure ("5: Input/output error") usually just means the service is already loaded from an earlier attempt — check with launchctl print gui/$(id -u)/com.david.shockbyte-backup before assuming something's actually broken.
The schedule
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>0</integer>
</dict>
Keeping the session alive
Fully automatic now, no calendar reminder needed. Every run of shockbyte_download_backup.py:
- Checks the real expiry of the saved session (the
KEYCLOAK_SESSIONcookie) before doing anything else. - If there's no saved session at all, or it's within 48 hours of expiring, proactively logs back in via
shockbyte_auth.py'slogin_and_refresh_state()(Keychain-based) before attempting the download — reusing its own already-launched browser for that login rather than spawning a second browser process. - If it still gets bounced to the login page during the actual download (session expired despite the above, or expired between the proactive check and the download itself), it runs the same refresh once more and retries the download a single time before giving up.
- As a safety net independent of all that, it also logs a loud warning once you're within 3 days of expiry, in case the automatic refresh itself ever silently stops working.
If the automated login itself ever breaks (e.g. Cloudflare starts blocking it again, or the Shockbyte login form changes), the script exits with code 1 and tells you to run shockbyte_login_setup.py by hand as a fallback.
Handling stalled downloads
Failure mode this closes off: Playwright's Download.save_as() (and .path()/.failure()) has no timeout of its own — per the docs, it "will wait for the download to finish if necessary," i.e. forever. A download that starts fine but then stalls mid-transfer (flaky network, Chrome deciding to pause it) used to hang the script indefinitely, leaving a stalled Chrome window sitting open and "waiting to resume" for hours, unnoticed.
Two layers now guard against this in shockbyte_download_backup.py:
- Per-download stall timeout. Once a download is confirmed,
save_as()runs on a side thread while the main thread waits up toDOWNLOAD_STALL_TIMEOUT_SECONDS(currently 600s / 10 min). If it's still running after that, the download is cancelled and treated as"stalled"rather than waited on forever. Because a stall may mean Chrome's own networking stack is wedged (not just the file), a stalled attempt retries with a brand-new browser instance rather than reusing the same one — up toMAX_DOWNLOAD_ATTEMPTS(currently 2) full attempts. - Outer browser-flow timeout. All of the above (session refresh, navigation, and every download attempt/retry) runs in a background daemon thread with its own deadline,
BROWSER_FLOW_TIMEOUT_SECONDS(currently 1800s / 30 min). This is the backstop for cases even the stall timeout can't catch — e.g.browser.close()'s known hang with real Chrome. If the outer deadline fires, the script force-kills the specific Chrome process that was still in flight viakill_marked_chrome(), using a unique--shockbyte-markerflag passed at launch (harmless to Chrome, which ignores unknown flags) pluspsutilto find and kill anything tagged with it. This is what prevents a stalled run from leaving an orphaned Chrome process running unattended overnight — the original failure mode this was built to fix.
A run that exhausts every retry (or times out at the outer level) exits with code 2, same as a page-structure-change error — check the log for which one it was.
backup.sh dependencies
mcaselector(prunes never-visited chunks) + a workingjava- Passwordless SSH to gmktec (agent-loaded key)
- A
~/Sync/ShockByte/...synced folder - An SMB share (
batcave) — currently mounted with a hardcoded username/password right in the script. Note to self: move this to Keychain too, same as the Shockbyte password.
If backup.sh fails, shockbyte_download_backup.py exits with code 4 (rather than the 0 it'd otherwise return) so a failure here is distinguishable in logs/monitoring from a successful full run.
Two safety guards were added to the original version of backup.sh: it now hard-stops before running any cleanup if unzip fails or if cd ~/Downloads/instance doesn't actually succeed (to avoid rm -rf running against the wrong directory), and the final rm -rf instance retries a couple of times with a short pause to absorb an intermittent "directory not empty" race with Spotlight/Finder regenerating .DS_Store files. The rest of the script (including the NAS-mount section's "log the error and keep going" behavior) is untouched deliberately: a blanket set -e would mean a single unreachable NAS some night aborts archiving to the local and synced locations too, which is worse than the current behavior of degrading gracefully.
Exit codes (shockbyte_download_backup.py)
0— backup downloaded successfully (andbackup.sh, if present, also succeeded)1— no valid session available, and automatic re-login also failed; runshockbyte_login_setup.pyby hand2— page structure changed / couldn't find expected elements, or the download stalled on every attempt3— Playwright not installed / other setup problem4— download succeeded butbackup.sh(post-processing) failed
Troubleshooting
launchctl bootstrapfails with "5: Input/output error" — usually means the service is already loaded from an earlier attempt. Check withlaunchctl print gui/$(id -u)/com.david.shockbyte-backup; if it prints something instead of erroring, it's already active — no fix needed (or runlaunchctl bootout gui/$(id -u)/com.david.shockbyte-backupfirst if you need to reload a changed plist). Otherwise check the plist's ownership/permissions (should berw-r--r--, owned by you) and validity (plutil -lint), and check for a leftover quarantine flag (xattr -d com.apple.quarantine ...orxattr -c ...to strip it if present).- "No saved session found" / "Got bounced to the login page" — the session expired or was never created. Run
shockbyte_login_setup_auto.py(orshockbyte_login_setup.pymanually if the automated one is misbehaving). - "Could not find the 'Extra Options' button" / "download-trigger icon" / any button-detection failure — Shockbyte changed their panel's page structure. These failure paths save a screenshot and HTML snapshot next to the script (
debug_*.png/debug_*.html) — share those and the selectors can be updated. - Couldn't get password from Keychain — confirm the Keychain item is a generic password (created via Keychain Access.app's "New Password Item", not something from Passwords.app/Safari's saved logins), named exactly
Shockbyte(or whateverKEYCHAIN_SERVICEis set to in the script). - Playwright errors about a missing browser — run
playwright install chromiumagain inside the venv. - Download stalls / script exits with code 2 after retries — see "Handling stalled downloads" above. Check the log for
"stalled"messages to confirm it's this rather than a page-structure error; if it keeps happening, it may point to a flaky network path or Shockbyte's download infra rather than anything in the script.
Bonus gotcha: tarsnap on gmktec wouldn't fire from /etc/crontab
Looked like it ran fine — showed up in the cron log every night — but no archive ever appeared. Turned out nothing was actually wrong with the job: /etc/crontab entries mail their output locally by default, and with no MTA configured on that box, any real error just vanished into the void, silently, forever.
Fix: redirect output explicitly so failures are actually visible.
0 3 * * * root tarsnap -c -f shockbyte-minecraft-`date +\%Y\%m\%d` /home/dhw/tarsnap-backups/shockbyte/minecraft/JJAC_Survival_World-vanilla >> /var/log/tarsnap-cron.log 2>&1
Known Playwright quirk: browser.close() can hang forever
Observed with real Chrome (channel="chrome") — happens after the important work (download, or a Keychain login) already succeeded, so no data is lost, but the script/terminal never gets control back without a Ctrl-C.
Fix: run the whole browser flow in a background daemon thread with a generous, bounded timeout. If the close hangs, the main thread just stops waiting and exits anyway — a stuck daemon thread can't block that.
ZFS-to-Tape Backup System — Setup Summary
Host: gmktec (FreeBSD 15.0-RELEASE). Tape drive: HP DAT72 USB (/dev/sa0 / /dev/nsa0, SCSI-over-USB via umass). Purpose: daily incremental backup of a Minecraft world to tape, on top of the existing tarsnap cloud backup of the same directory.
What's backed up
- Live path:
/home/dhw/tarsnap-backups/shockbyte/minecraft/JJAC_Survival_World-vanilla - ZFS dataset:
zroot/home/dhw/jjac-survival-world(created specifically for this — was previously just a subdirectory of thezroot/home/dhwdataset; carved out into its own dataset, mounted at the same path, so nothing else needed to change) - Updated daily via rsync from another box.
Scripts
Two scripts, deployed to /usr/local/sbin/:
zfs-tape-backup.sh— run daily via cron (0 2 * * *). Takes a dated ZFS snapshot, sends a FULL stream on first run (or everyFULL_INTERVAL_DAYS, default 30) or an INCREMENTAL stream otherwise, appends it to tape via the no-rewind device. Tracks state (last snapshot, tape file position, cumulative bytes written) in/var/db/zfs-tape-backup/state, with a permanent per-run history in/var/db/zfs-tape-backup/tape-index.log. Logs to/var/log/zfs-tape-backup.log.zfs-tape-restore.sh <target-dataset> [num-files]— reads tape files in order from the beginning (full, then each incremental) into a target dataset.
zfs-tape-backup.sh
#!/bin/sh
#
# zfs-tape-backup.sh
#
# Daily snapshot + incremental zfs send to a no-rewind tape device.
# Writes a FULL send on the first run (or when the full-backup interval
# has elapsed) and an INCREMENTAL send (relative to the previous
# snapshot) on every other run. Each send is appended to the tape as a
# new tape file, since /dev/nsa0 does not rewind after writing.
#
# Edit the CONFIG section below for your setup, then run this from
# cron (see the example at the bottom of this file).
set -eu -o pipefail
# Block size for writes hitting the tape device. Must not exceed the
# controller's si_iosize_max (commonly 65536 / 64k on USB SCSI bridges -
# check dmesg for the actual value if you hit "cannot split request").
# zfs send's own output buffer size can exceed this, so we always pipe
# through dd to re-chunk writes to a safe size.
TAPE_BS="64k"
# Capacity tracking. DAT72 is rated at 36GB native (uncompressed) capacity
# (decimal GB, i.e. 36,000,000,000 bytes) - this is the manufacturer spec,
# not a measured value, so treat it as approximate. We track cumulative
# bytes actually written (post-compression, since that's what dd reports)
# and warn once you cross CAPACITY_WARN_PERCENT of that figure.
TAPE_NATIVE_CAPACITY_BYTES=36000000000
CAPACITY_WARN_PERCENT=80
# Email alerting. Uses the system mail(1) command, which routes through
# whatever MTA is configured in /etc/mail/mailer.conf (DMA by default on
# FreeBSD 14+). Set to "" to disable email alerts entirely.
ALERT_EMAIL="david@gromzen.com"
# ---------------------------------------------------------------------
# CONFIG - edit these for your environment
# ---------------------------------------------------------------------
DATASET="zroot/home/dhw/jjac-survival-world" # the ZFS dataset holding the world dir
TAPE="/dev/nsa0" # no-rewind tape device
STATE_DIR="/var/db/zfs-tape-backup"
STATE_FILE="$STATE_DIR/state" # tracks last snapshot + tape position
LOG_FILE="/var/log/zfs-tape-backup.log"
FULL_INTERVAL_DAYS=30 # take a fresh full backup this often
LOCK_FILE="/var/run/zfs-tape-backup.lock"
# ---------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------
mkdir -p "$STATE_DIR"
touch "$LOG_FILE"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"
}
mail_alert() {
# $1 = subject, $2 = body
if [ -z "$ALERT_EMAIL" ]; then
return 0
fi
if echo "$2" | mail -s "$1" "$ALERT_EMAIL"; then
log "Sent alert email: $1"
else
log "WARNING: failed to send alert email: $1"
fi
}
# Prevent overlapping runs
if [ -e "$LOCK_FILE" ]; then
log "ERROR: lock file $LOCK_FILE exists, another run may be in progress. Aborting."
mail_alert "ZFS tape backup did not run on $(hostname)" \
"Lock file $LOCK_FILE already exists - a previous run may be stuck or crashed. Backup was skipped. Check $LOG_FILE."
exit 1
fi
trap 'rm -f "$LOCK_FILE"' EXIT
touch "$LOCK_FILE"
TODAY=$(date '+%Y-%m-%d')
SNAP_NAME="${DATASET}@${TODAY}"
# ---------------------------------------------------------------------
# Read previous state, if any
# ---------------------------------------------------------------------
LAST_SNAP=""
LAST_FULL_DATE=""
TAPE_FILE_POS=0
TOTAL_BYTES_WRITTEN=0
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
. "$STATE_FILE"
fi
# Guard against running twice in one day
if [ "$SNAP_NAME" = "$LAST_SNAP" ]; then
log "Snapshot $SNAP_NAME already exists / already backed up today. Nothing to do."
exit 0
fi
# ---------------------------------------------------------------------
# Decide full vs incremental
# ---------------------------------------------------------------------
DO_FULL=0
if [ -z "$LAST_SNAP" ]; then
DO_FULL=1
log "No previous state found - performing initial FULL backup."
elif [ -z "$LAST_FULL_DATE" ]; then
DO_FULL=1
else
DAYS_SINCE_FULL=$(( ( $(date -j -f '%Y-%m-%d' "$TODAY" '+%s') - $(date -j -f '%Y-%m-%d' "$LAST_FULL_DATE" '+%s') ) / 86400 ))
if [ "$DAYS_SINCE_FULL" -ge "$FULL_INTERVAL_DAYS" ]; then
DO_FULL=1
log "Full-backup interval reached ($DAYS_SINCE_FULL days since last full) - performing FULL backup."
fi
fi
# ---------------------------------------------------------------------
# Reposition the tape to the correct append point
# ---------------------------------------------------------------------
# We never trust that the tape is already sitting wherever the last
# successful run left it. Instead, every run explicitly rewinds to the
# beginning and forward-spaces past exactly the number of known-good
# files ($TAPE_FILE_POS). This is deliberate, not just belt-and-suspenders:
#
# - A rewind is one of the documented ways to clear the sa(4) driver's
# "frozen" state (set when a previous write failed to close out its
# terminating filemark(s) - see /var/log/messages for
# "tape is now frozen" if this has happened before). So this step
# doubles as automatic recovery from that state, without needing a
# manual `mt rewind` or reboot.
# - It makes positioning self-correcting regardless of *why* the tape
# might be in the wrong place - not just this one known failure mode.
#
# Runs BEFORE any ZFS snapshot is taken, and if it fails, we abort
# loudly rather than risk writing (and overwriting existing backups)
# at the wrong tape position.
log "Repositioning tape: rewind, then forward-space $TAPE_FILE_POS file(s)"
if ! mt -f "$TAPE" rewind >>"$LOG_FILE" 2>&1; then
log "ERROR: mt rewind failed while repositioning tape. Aborting before touching ZFS."
mail_alert "ZFS tape backup FAILED on $(hostname)" \
"mt rewind failed while repositioning the tape before today's backup. No snapshot was taken and nothing was written. Check $LOG_FILE and consider checking the drive/cabling in person."
exit 1
fi
if [ "$TAPE_FILE_POS" -gt 0 ]; then
if ! mt -f "$TAPE" fsf "$TAPE_FILE_POS" >>"$LOG_FILE" 2>&1; then
log "ERROR: mt fsf $TAPE_FILE_POS failed while repositioning tape. Aborting before touching ZFS."
mail_alert "ZFS tape backup FAILED on $(hostname)" \
"mt fsf $TAPE_FILE_POS failed while repositioning the tape before today's backup. No snapshot was taken and nothing was written. This could mean the tape doesn't contain as much data as expected (wrong/blank tape loaded?) - check $LOG_FILE and verify the correct tape is in the drive before retrying."
exit 1
fi
fi
log "Tape repositioned successfully to file #$TAPE_FILE_POS"
# ---------------------------------------------------------------------
# Take today's snapshot
# ---------------------------------------------------------------------
if ! zfs snapshot "$SNAP_NAME"; then
log "ERROR: zfs snapshot $SNAP_NAME failed. Aborting."
mail_alert "ZFS tape backup FAILED on $(hostname)" \
"zfs snapshot $SNAP_NAME failed - backup did not run. Check $LOG_FILE."
exit 1
fi
log "Created snapshot $SNAP_NAME"
# ---------------------------------------------------------------------
# Send to tape
# ---------------------------------------------------------------------
DD_STATS=$(mktemp)
if [ "$DO_FULL" -eq 1 ]; then
log "Sending FULL stream of $SNAP_NAME to $TAPE (tape file #$TAPE_FILE_POS, bs=$TAPE_BS)"
if ! zfs send "$SNAP_NAME" | dd of="$TAPE" bs="$TAPE_BS" 2>"$DD_STATS"; then
cat "$DD_STATS" >> "$LOG_FILE"
log "ERROR: zfs send (full) to $TAPE failed."
mail_alert "ZFS tape backup FAILED on $(hostname)" \
"FULL backup of $SNAP_NAME to $TAPE failed. See $LOG_FILE for details.
$(cat "$DD_STATS")"
rm -f "$DD_STATS"
zfs destroy "$SNAP_NAME" 2>/dev/null || true
exit 1
fi
SEND_TYPE="FULL"
LAST_FULL_DATE="$TODAY"
else
log "Sending INCREMENTAL stream ${LAST_SNAP} -> ${SNAP_NAME} to $TAPE (tape file #$TAPE_FILE_POS, bs=$TAPE_BS)"
if ! zfs send -i "$LAST_SNAP" "$SNAP_NAME" | dd of="$TAPE" bs="$TAPE_BS" 2>"$DD_STATS"; then
cat "$DD_STATS" >> "$LOG_FILE"
log "ERROR: zfs send -i $LAST_SNAP $SNAP_NAME to $TAPE failed."
mail_alert "ZFS tape backup FAILED on $(hostname)" \
"INCREMENTAL backup ($LAST_SNAP -> $SNAP_NAME) to $TAPE failed. See $LOG_FILE for details.
$(cat "$DD_STATS")"
rm -f "$DD_STATS"
zfs destroy "$SNAP_NAME" 2>/dev/null || true
exit 1
fi
SEND_TYPE="INCREMENTAL"
fi
cat "$DD_STATS" >> "$LOG_FILE"
# Parse the byte count out of dd's summary line, e.g.:
# "1140916072 bytes transferred in 335.187011 secs (3403819 bytes/sec)"
# Fall back to 0 if the format ever changes, rather than failing the backup.
BYTES_THIS_RUN=$(grep -oE '^[0-9]+ bytes transferred' "$DD_STATS" | awk '{print $1}')
rm -f "$DD_STATS"
if [ -z "${BYTES_THIS_RUN:-}" ]; then
log "WARNING: could not parse bytes-written from dd output; capacity tracking for this run skipped."
BYTES_THIS_RUN=0
fi
TOTAL_BYTES_WRITTEN=$((TOTAL_BYTES_WRITTEN + BYTES_THIS_RUN))
log "Wrote $SEND_TYPE stream for $SNAP_NAME as tape file #$TAPE_FILE_POS ($BYTES_THIS_RUN bytes)"
# ---------------------------------------------------------------------
# Capacity check
# ---------------------------------------------------------------------
CAPACITY_PERCENT=$((TOTAL_BYTES_WRITTEN * 100 / TAPE_NATIVE_CAPACITY_BYTES))
log "Cumulative bytes written to this tape: $TOTAL_BYTES_WRITTEN / $TAPE_NATIVE_CAPACITY_BYTES (${CAPACITY_PERCENT}% of rated native capacity)"
if [ "$CAPACITY_PERCENT" -ge "$CAPACITY_WARN_PERCENT" ]; then
log "WARNING: tape is at ${CAPACITY_PERCENT}% of its rated native capacity (threshold: ${CAPACITY_WARN_PERCENT}%). Consider rotating in a fresh tape soon."
mail_alert "ZFS tape backup: tape nearing capacity on $(hostname)" \
"Tape is at ${CAPACITY_PERCENT}% of its rated native capacity (${TOTAL_BYTES_WRITTEN} / ${TAPE_NATIVE_CAPACITY_BYTES} bytes).
Warning threshold: ${CAPACITY_WARN_PERCENT}%.
Consider rotating in a fresh tape soon. See $STATE_DIR/tape-index.log for the full history on this tape."
fi
# ---------------------------------------------------------------------
# Update state
# ---------------------------------------------------------------------
NEW_TAPE_FILE_POS=$((TAPE_FILE_POS + 1))
cat > "$STATE_FILE" <<EOF
LAST_SNAP="$SNAP_NAME"
LAST_FULL_DATE="$LAST_FULL_DATE"
TAPE_FILE_POS=$NEW_TAPE_FILE_POS
TOTAL_BYTES_WRITTEN=$TOTAL_BYTES_WRITTEN
EOF
# Also append a permanent record to the log of what's on the tape and
# where, since $STATE_FILE only tracks the *current* pointer.
echo "$(date '+%Y-%m-%d %H:%M:%S') TAPE_FILE=$TAPE_FILE_POS TYPE=$SEND_TYPE SNAP=$SNAP_NAME BYTES=$BYTES_THIS_RUN CUMULATIVE_BYTES=$TOTAL_BYTES_WRITTEN" >> "$STATE_DIR/tape-index.log"
log "Backup complete. State updated: LAST_SNAP=$SNAP_NAME TAPE_FILE_POS=$NEW_TAPE_FILE_POS"
exit 0
# ---------------------------------------------------------------------
# Example cron entry (run as root, daily at 2am):
# 0 2 * * * /usr/local/sbin/zfs-tape-backup.sh
#
# When you swap in a fresh/blank tape, delete $STATE_FILE (but keep
# tape-index.log for your records, or archive it) so the next run
# starts over with a FULL backup at tape file #0 and resets the
# cumulative capacity counter (TOTAL_BYTES_WRITTEN) to 0.
# ---------------------------------------------------------------------
zfs-tape-restore.sh
#!/bin/sh
#
# zfs-tape-restore.sh
#
# Restores a dataset from a tape written by zfs-tape-backup.sh.
# Reads tape files in order starting from the beginning, receiving
# the full stream first and then each incremental in sequence.
#
# Usage: zfs-tape-restore.sh <target-dataset> [num-files]
#
# target-dataset e.g. zroot/home/dhw/jjac-survival-world-restore
# (must not already exist)
# num-files how many tape files to restore (default: all files
# recorded in the tape-index.log)
#
# Check /var/db/zfs-tape-backup/tape-index.log first to see how many
# files are on the tape and what each one is (FULL or INCREMENTAL).
#
# Example:
# zfs-tape-restore.sh zroot/home/dhw/jjac-survival-world-restore
set -eu -o pipefail
TAPE="/dev/nsa0"
TAPE_BS="64k" # must match the block size used when writing (see zfs-tape-backup.sh)
INDEX_LOG="/var/db/zfs-tape-backup/tape-index.log"
TARGET="${1:-}"
if [ -z "$TARGET" ]; then
echo "Usage: $0 <target-dataset> [num-files]" >&2
exit 1
fi
if [ -n "${2:-}" ]; then
NUM_FILES="$2"
elif [ -f "$INDEX_LOG" ]; then
NUM_FILES=$(wc -l < "$INDEX_LOG" | tr -d ' ')
else
echo "No tape-index.log found and no file count given - specify num-files explicitly." >&2
exit 1
fi
echo "Restoring $NUM_FILES tape file(s) into dataset: $TARGET"
echo "Rewinding tape..."
mt -f "$TAPE" rewind
i=0
while [ "$i" -lt "$NUM_FILES" ]; do
if [ "$i" -eq 0 ]; then
echo "Receiving tape file #0 (expected FULL stream)..."
dd if="$TAPE" bs="$TAPE_BS" | zfs receive "$TARGET"
else
echo "Receiving tape file #$i (expected INCREMENTAL stream)..."
dd if="$TAPE" bs="$TAPE_BS" | zfs receive -F "$TARGET"
# -F rolls the target back to match the incoming incremental's
# origin snapshot if needed; safe here since we're restoring
# in strict order into a dedicated restore target.
fi
i=$((i + 1))
# No explicit fsf needed here: per sa(4), once a read consumes the
# terminating zero-length read at a filemark, the no-rewind device
# is already positioned at the start of the next tape file.
done
echo "Restore complete. Snapshots now present on $TARGET:"
zfs list -t snapshot -r "$TARGET"
Key technical issues hit and fixed
-
Block size mismatch (
si_iosize_max) — the USB SCSI bridge in the DAT72 adapter caps single writes at 65536 bytes (64KB).zfs send's own write buffer exceeds this, causingcannot split requesterrors. Fix: always pipezfs sendthroughdd of=/dev/nsa0 bs=64kto re-chunk writes to a safe size, rather than writing directly. -
Frozen tape state — an aborted write (triggered by the block-size issue) left the
sa(4)driver in a "frozen" state (visible indmesg, not inmt status), refusing further I/O until explicitly cleared, since a failed write can leave the terminating filemark(s) unclosed. Fix:mt -f /dev/nsa0 rewindclears it — and this is no longer just a manual recovery step. The backup script now rewinds and re-forward-spaces to the correct tape file (mt rewind, thenmt fsf $TAPE_FILE_POS) at the start of every run, before touching ZFS. This both self-corrects tape position each time (rather than trusting wherever the last run left off) and doubles as automatic recovery from the frozen state, so a prior failed write no longer requires manual intervention. If the rewind/reposition step itself fails, the script aborts before taking a snapshot or writing anything, and sends an email alert. -
Restore positioning bug — the original restore script called
mt fsf 1between reads to advance to the next tape file. This was wrong: persa(4), once a sequential read consumes the terminating zero-length read at a filemark, the no-rewind device is already positioned at the start of the next file. The extrafsfskipped past the real next file onto nonexistent data, causing an I/O error. Fix: removed the explicit skip; reads now walk file-to-file on their own. -
Validating restores against a moving target — diffing a restored copy against the live source directory is unreliable if rsync runs in between, since it compares two different points in time. Fix: diff against the ZFS snapshot's frozen state instead, via the hidden
.zfs/snapshot/<name>path, e.g.:
Both full and full+incremental restore chains have been validated this way with a clean (no-diff) result.diff -rq /home/dhw/.../JJAC_Survival_World-vanilla/.zfs/snapshot/2026-07-16 \ "$(zfs get -H -o value mountpoint <restored-dataset>)"
Capacity tracking
- DAT72 native capacity: ~36,000,000,000 bytes (manufacturer spec, not measured).
- The backup script parses
dd's own byte-transfer count each run, keeps a running total instate, and logs the percentage of rated capacity used after every backup. - Logs a
WARNING(and sends an email alert) once cumulative usage crosses 80% (CAPACITY_WARN_PERCENT) — currently fires on every run past that threshold, not just once; not yet deduplicated. - When rotating in a fresh tape: delete
/var/db/zfs-tape-backup/state(keep or archivetape-index.log) so the next run starts a new FULL backup at tape file #0 with the byte counter reset.
Email alerting
- MTA: DMA (DragonFly Mail Agent) — FreeBSD's default MTA since 14.0, already present in base (
sendmailis a DMA-provided compatibility shim; confirmed via/etc/mail/mailer.conf→/usr/libexec/dma). - Relay: Hover's SMTP server,
mail.hover.com:465, implicit TLS (not STARTTLS). - Config files:
/etc/dma/dma.conf—SMARTHOST,PORT 465,AUTHPATH,SECURETRANSFER,MAILNAME, and criticallyMASQUERADE david@gromzen.com(without this, mail was rejected by Hover because the local envelope-from, e.g.root@gmktec.internal, didn't match the authenticated account)./etc/dma/auth.conf—user|host:passwordformat. Must be group-ownedmail(chgrp mail,chmod 640) — DMA drops privileges before reading it, and owner-only (600) permissions cause aPermission deniedeven when invoked as root.
- The backup script emails on: send failures (full or incremental), snapshot creation failure, a stuck lock file (possible crashed prior run), and the capacity warning threshold. Recipient set via
ALERT_EMAILin the script (set to""to disable). - Test command:
echo "test" | mail -v -s "subject" you@yourdomain.com, and check/var/log/maillogfor the actual SMTP-level result.
Cron
0 2 * * * /usr/local/sbin/zfs-tape-backup.sh
Add via /etc/crontab (needs a username field: 0 2 * * * root /usr/local/sbin/zfs-tape-backup.sh) or crontab -e — whichever's more reliable in your environment.
Open items / possible follow-ups
- Capacity warning currently re-fires daily once past threshold — could add a one-time "already warned" flag to
stateif repeated emails become noisy. - Periodic restore testing isn't automated — worth manually re-testing every month or so, since tape/drive issues can develop silently.
msmtpwas considered as an alternative to DMA but wasn't needed once DMA (already present in FreeBSD base) was configured correctly.- If the frozen-tape state keeps recurring despite the auto-reposition fix, consider replacing the USB DAT72 adapter with a native SCSI unit — the USB-SCSI bridge (and its
si_iosize_maxlimitation) is the likely root cause, and a direct SCSI connection would sidestep it entirely.