Fetch backups over scp with a key, not cat through a pty

Each nightly backup is ~275 KB and was pulled with `cat` through an expect
pseudo-terminal, then recovered by slicing from the first { to the last } of the
transcript. A pty does line discipline and has finite buffers — it is not a file
transfer — so captures arrived truncated often enough to fail the JSON check most
nights between 29 September and 8 October, emailing each time. No backup was ever
lost; every file copied on a later attempt.

The ingest Mac now has a key authorised on the NAS, so there is no password prompt
to automate and the file comes over scp, byte for byte. expect is gone from this
script, and so is the NAS password: nothing here reads the keychain any more,
which also removes the reason it could not be run by hand over SSH.

The JSON check stays, but it only verifies now — there is no transcript to repair.
A backup that is present but unreadable is still worse than one that is obviously
missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-08 09:36:38 -04:00
co-authored by Claude Opus 5
parent 5db4aada74
commit cf3c496c17
+34 -35
View File
@@ -57,13 +57,27 @@ for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do
done done
done done
# The SSH password comes from the login keychain — the same entry build-deploy.sh reads, so # Key-based SSH. This used to pull each file with `cat` through an expect pseudo-terminal,
# there is still one copy of the credential to rotate, and it is not in the project at all. # which is how a ~275 KB JSON arrived truncated often enough to email a false alarm most
# Store it with: security add-generic-password -U -s checklist-nas -a Christian -w # nights between 29 September and 8 October 2026. A pty does line discipline and has finite
NAS_PASS="${NAS_PASS:-$(security find-generic-password -s checklist-nas -a Christian -w 2>/dev/null || true)}" # buffers; it is not a file transfer. With a key there is no password prompt to automate, so
if [ -z "$NAS_PASS" ]; then # the file can come over scp and be byte-exact.
die "no NAS password in the keychain" \ #
"Store it with: security add-generic-password -U -s checklist-nas -a Christian -w" # The key lives on this machine only and is authorised on the NAS for this user. There is no
# password here any more, and nothing to read out of the keychain.
SSH_KEY="$HOME/.ssh/id_ed25519_checklist_nas"
SSH_OPTS="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 -i $SSH_KEY"
if [ ! -f "$SSH_KEY" ]; then
die "no SSH key for the NAS at $SSH_KEY" \
"Generate one and authorise it on the NAS:
ssh-keygen -t ed25519 -f $SSH_KEY -N ''
ssh-copy-id -i $SSH_KEY $NAS_USER@$NAS_HOST"
fi
if ! ssh $SSH_OPTS "$NAS_USER@$NAS_HOST" true 2>/dev/null; then
die "cannot reach the NAS over SSH" \
"Key auth to $NAS_USER@$NAS_HOST failed. Either the NAS is down, or the key at
$SSH_KEY is no longer in its authorized_keys."
fi fi
# Google Drive streams files on demand; if the app is signed out or not running the path # Google Drive streams files on demand; if the app is signed out or not running the path
@@ -78,17 +92,8 @@ folder still exist under that name?"
fi fi
say "Drive folder: $DRIVE_DIR" say "Drive folder: $DRIVE_DIR"
run_remote() { # $1 = remote command; prints stdout, strips the pty's CRs run_remote() { # $1 = remote command
local cmd="$1" ssh $SSH_OPTS "$NAS_USER@$NAS_HOST" "$1"
expect <<EXP 2>/dev/null | tr -d '\r'
set timeout 240
log_user 1
spawn bash -c "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=20 -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 $NAS_USER@$NAS_HOST \"$cmd\""
expect {
-nocase "password:" { send "$NAS_PASS\r"; exp_continue }
eof
}
EXP
} }
# 1. What does the NAS have? # 1. What does the NAS have?
@@ -120,28 +125,22 @@ else
[ -z "$f" ] && continue [ -z "$f" ] && continue
n=$((n+1)); [ $n -gt $MAX_PER_RUN ] && { say "· stopping at $MAX_PER_RUN this run"; break; } n=$((n+1)); [ $n -gt $MAX_PER_RUN ] && { say "· stopping at $MAX_PER_RUN this run"; break; }
TMP="$(mktemp -t checklist-backup)"; OUT="$(mktemp -t checklist-backup-json)" TMP="$(mktemp -t checklist-backup)"; OUT="$(mktemp -t checklist-backup-json)"
# Retried, because the fetch is the fragile part. Each of these files is ~275 KB and it # Still retried, though the reason it used to be needed is gone. The truncation that
# comes back through an expect pseudo-terminal, which does line discipline and has finite # failed a file most nights between 29 September and 7 October 2026 came from pulling it
# buffers — so a capture occasionally arrives truncated and fails to parse. That is a # through a pty; scp cannot truncate silently. A network blip mid-copy is still possible,
# transport hiccup, not a corrupt backup: every file that failed this way between # and retrying costs a few seconds against an email nobody needed.
# 2026-09-29 and 2026-10-07 copied cleanly on a later night. Retrying in the same run
# turns a nightly email into a non-event.
ok=0 ok=0
for try in 1 2 3; do for try in 1 2 3; do
: > "$TMP" : > "$TMP"
run_remote "cat $REMOTE_DIR/$f" > "$TMP" scp -q $SSH_OPTS "$NAS_USER@$NAS_HOST:$REMOTE_DIR/$f" "$TMP" 2>/dev/null || true
# expect echoes its own `spawn …` banner and the password prompt onto stdout before the # scp delivers the file byte for byte, so there is nothing to slice out of a transcript
# file's contents, so the capture is never pure JSON. Slice from the first { to the last } # any more. The check stays because a backup that is present but unreadable is worse
# and only then validate — a truncated or half-read file that *looks* like a backup is # than one that is obviously missing — it just verifies rather than repairs now.
# worse than an obviously missing one.
if node -e ' if node -e '
const fs=require("fs"); const fs=require("fs");
const t=fs.readFileSync(process.argv[1],"utf8"); const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); // throws => non-zero
const a=t.indexOf("{"), b=t.lastIndexOf("}"); if(!Array.isArray(j.lists)) process.exit(1); // shape, not just syntax
if(a<0||b<=a) process.exit(1); fs.copyFileSync(process.argv[1], process.argv[2]);
const j=JSON.parse(t.slice(a,b+1)); // throws => exit non-zero
if(!Array.isArray(j.lists)) process.exit(1); // shape check, not just valid JSON
fs.writeFileSync(process.argv[2], JSON.stringify(j));
' "$TMP" "$OUT" 2>/dev/null; then ' "$TMP" "$OUT" 2>/dev/null; then
ok=1; break ok=1; break
fi fi