taproot
mirrorThe dotfiles and containers I use to set up a machine for development, one container to write code in and another that runs a local coding model on the desktop's GPU.
alpine-linuxcaddydevelopment-environmentdockerdotfileshomelabinfrastructureneovimserver-configurationtmux
1#!/bin/sh
2#
3# restic-setup.sh
4#
5# Check the restic credentials in ~/.restic, and walk through entering them if
6# they are missing or wrong, so setting up backups on a fresh container is one
7# command that says what it wants rather than two files to open in nvim and
8# get subtly wrong.
9#
10# restic-setup check, then offer to fix whatever is wrong
11# restic-setup --check check only, quietly; exit 0 if backups will work
12# restic-setup --password print a suggested password and exit, writing nothing
13#
14# The files it manages, all in the bythewood-restic volume:
15#
16# ~/.restic/b2-env B2_ACCOUNT_ID, B2_ACCOUNT_KEY, RESTIC_HOST
17# ~/.restic/password password for the webdev repo
18#
19# From a taproot clone this is `make restic`, and --check is what `make doctor`
20# calls, so keep it silent and keep its exit code meaningful.
21
22set -eu
23
24RESTIC_DIR="$HOME/.restic"
25B2_ENV="$RESTIC_DIR/b2-env"
26PW_FILE="$RESTIC_DIR/password"
27REPO="b2:overshard-backups:webdev"
28
29# Reads the header above rather than a line range, so editing the comment cannot
30# silently turn --help into someone else's text.
31usage() {
32 awk 'NR>1 && /^#/ { sub(/^#[ ]?/, ""); print; next } NR>1 { exit }' "$0"
33}
34
35# 32 characters of /dev/urandom in groups of eight. Long enough that nobody is
36# ever going to type it by hand, grouped so it can be read back off a screen.
37gen_password() {
38 LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom \
39 | head -c 32 \
40 | sed 's/.\{8\}/&-/g; s/-$//'
41 echo ""
42}
43
44CHECK_ONLY=no
45case "${1:-}" in
46--check) CHECK_ONLY=yes ;;
47--password)
48 # The password alone on stdout and the note on stderr, so it pipes cleanly.
49 gen_password
50 echo "" >&2
51 echo "a suggestion, nothing was written. put it in 1Password." >&2
52 exit 0 ;;
53--help|-h) usage; exit 0 ;;
54"") ;;
55*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
56esac
57
58# Everything below reads the current state without changing it, so the check
59# path and the interactive path agree on what is wrong.
60B2_ID=""
61B2_KEY=""
62HOSTTAG=""
63if [ -f "$B2_ENV" ]; then
64 # A subshell, so a malformed b2-env cannot leak variables or a `set -e`
65 # failure into this script.
66 eval "$(
67 . "$B2_ENV" >/dev/null 2>&1 || true
68 printf 'B2_ID=%s\n' "$(printf '%s' "${B2_ACCOUNT_ID:-}" | sed "s/'/'\\\\''/g; s/^/'/; s/$/'/")"
69 printf 'B2_KEY=%s\n' "$(printf '%s' "${B2_ACCOUNT_KEY:-}" | sed "s/'/'\\\\''/g; s/^/'/; s/$/'/")"
70 printf 'HOSTTAG=%s\n' "$(printf '%s' "${RESTIC_HOST:-}" | sed "s/'/'\\\\''/g; s/^/'/; s/$/'/")"
71 )"
72fi
73
74have_all_fields() {
75 [ -n "$B2_ID" ] && [ -n "$B2_KEY" ] && [ -n "$HOSTTAG" ] && [ -s "$PW_FILE" ]
76}
77
78# Opening the repository is the only check that proves anything, since a wrong
79# key, a wrong password and no network all land here. Timed out because restic
80# retries a failing backend about ten times with backoff and `make doctor`
81# calls this. 124 is the timeout's own exit code.
82RESTIC_TIMEOUT=${RESTIC_TIMEOUT:-30}
83restic_probe() {
84 B2_ACCOUNT_ID="$B2_ID" B2_ACCOUNT_KEY="$B2_KEY" \
85 RESTIC_REPOSITORY="$REPO" RESTIC_PASSWORD_FILE="$PW_FILE" \
86 timeout "$RESTIC_TIMEOUT" restic cat config "$@"
87}
88
89repo_opens() {
90 restic_probe >/dev/null 2>&1
91}
92
93if [ "$CHECK_ONLY" = yes ]; then
94 have_all_fields || exit 1
95 repo_opens || exit 1
96 exit 0
97fi
98
99mask() {
100 v=$1
101 n=$(printf '%s' "$v" | wc -c)
102 if [ -z "$v" ]; then printf 'not set'
103 elif [ "$n" -le 8 ]; then printf '********'
104 else printf '%s...%s' "$(printf '%s' "$v" | cut -c1-4)" "$(printf '%s' "$v" | cut -c$((n-1))-)"
105 fi
106}
107
108echo "restic credentials in $RESTIC_DIR"
109echo ""
110printf ' %-18s %s\n' "B2_ACCOUNT_ID" "$(mask "$B2_ID")"
111printf ' %-18s %s\n' "B2_ACCOUNT_KEY" "$(mask "$B2_KEY")"
112printf ' %-18s %s\n' "RESTIC_HOST" "${HOSTTAG:-not set}"
113printf ' %-18s %s\n' "repo password" "$([ -s "$PW_FILE" ] && echo set || echo 'not set')"
114echo ""
115
116if have_all_fields; then
117 printf 'opening %s ... ' "$REPO"
118 if repo_opens; then
119 echo "ok"
120 echo ""
121 echo "backups are working. nothing to do here."
122 echo ""
123 echo " make backup take a snapshot from this machine"
124 echo " make snapshots last snapshot per host, and what the repo costs"
125 exit 0
126 fi
127 echo "FAILED"
128 echo ""
129 echo "the files are filled in but the repository will not open. that is"
130 echo "usually a revoked or mistyped B2 key, a wrong repo password, or no"
131 echo "network. entering them again is the fastest way to find out which."
132else
133 echo "something is missing, so backups will not run yet."
134fi
135
136echo ""
137
138if [ ! -t 0 ]; then
139 echo "this needs a terminal to read the values. run it as:" >&2
140 echo "" >&2
141 echo " make restic" >&2
142 exit 1
143fi
144
145cat <<'GUIDE'
146------------------------------------------------------------------------
147where these come from
148
149 Everything is in 1Password first. Look for the Backblaze B2 item (the
150 application key) and the restic repository password. If both are there,
151 paste them below and you are done.
152
153 If you need a NEW B2 application key, at https://secure.backblaze.com
154
155 1. Buckets. There should be a private bucket named overshard-backups.
156 Create it if it is gone: private, no encryption, no object lock.
157 Restic does its own encryption, and object lock would break prune.
158
159 2. Application Keys, then "Add a New Application Key".
160 name anything, e.g. webdev-desktop
161 bucket overshard-backups, not "All"
162 access Read and Write
163 leave the file-name prefix and duration empty
164
165 3. Save it. The keyID and the applicationKey are shown ONCE. The
166 keyID is B2_ACCOUNT_ID, the applicationKey is B2_ACCOUNT_KEY.
167 Put both back into 1Password before you close the page.
168
169 The repo password is NOT recoverable. If it is lost, the snapshots in
170 that repository are lost with it, and the only fix is a new repository.
171------------------------------------------------------------------------
172GUIDE
173echo ""
174
175# Enter keeps whatever is already there, so one field can be changed without
176# retyping the rest. These set a global rather than printing a value, because
177# inside $(...) the prompt is captured too and you end up typing blind.
178ANSWER=""
179
180ask() {
181 printf '%s' "$1"
182 [ -n "$2" ] && printf ' [enter keeps %s]' "$(mask "$2")"
183 printf ': '
184 read -r ANSWER || ANSWER=""
185 [ -n "$ANSWER" ] || ANSWER=$2
186}
187
188ask_secret() {
189 printf '%s' "$1"
190 [ -n "$2" ] && printf ' [enter keeps it]'
191 printf ': '
192 stty -echo 2>/dev/null || true
193 read -r ANSWER || ANSWER=""
194 stty echo 2>/dev/null || true
195 printf '\n'
196 [ -n "$ANSWER" ] || ANSWER=$2
197}
198
199ask "B2 keyID" "$B2_ID"; NEW_ID=$ANSWER
200ask_secret "B2 applicationKey" "$B2_KEY"; NEW_KEY=$ANSWER
201
202# Retention is applied per host, so a typo here quietly gives a third machine
203# its own 7/4/6 window and stops pruning the real one.
204while :; do
205 ask "this machine, desktop or laptop" "$HOSTTAG"
206 NEW_HOST=$ANSWER
207 case "$NEW_HOST" in
208 desktop|laptop) break ;;
209 "") echo " nothing entered, and nothing to keep" ;;
210 *) echo " must be exactly 'desktop' or 'laptop'" ;;
211 esac
212done
213
214CURRENT_PW=""
215[ -s "$PW_FILE" ] && CURRENT_PW=$(cat "$PW_FILE")
216
217# A generated one only works for a repository that does not exist yet, and the
218# second machine has to be given the first one's password instead, so it is
219# offered rather than just written.
220SUGGESTED=""
221if [ -n "$CURRENT_PW" ]; then
222 ask_secret "restic repository password" "$CURRENT_PW"; NEW_PW=$ANSWER
223else
224 SUGGESTED=$(gen_password)
225 echo ""
226 echo "there is no repo password here yet, so here is one:"
227 echo ""
228 echo " $SUGGESTED"
229 echo ""
230 echo "press enter to take it, and put it in 1Password now, because it is"
231 echo "not recoverable and nothing else has a copy. if this machine is"
232 echo "joining backups that already exist, paste that repository's own"
233 echo "password over it instead, since a new one will not open it."
234 echo ""
235 printf 'restic repository password [enter takes the one above]: '
236 read -r ANSWER || ANSWER=""
237 NEW_PW=${ANSWER:-$SUGGESTED}
238 [ "$NEW_PW" = "$SUGGESTED" ] || SUGGESTED=""
239fi
240
241if [ -z "$NEW_ID" ] || [ -z "$NEW_KEY" ] || [ -z "$NEW_PW" ]; then
242 echo "" >&2
243 echo "one of the values is empty. nothing was written." >&2
244 exit 1
245fi
246
247# Written to a temp file and moved into place, so an interrupted run cannot
248# leave half a credentials file behind.
249mkdir -p "$RESTIC_DIR"
250chmod 700 "$RESTIC_DIR"
251
252umask 077
253
254tmp="$B2_ENV.tmp.$$"
255cat > "$tmp" <<ENV
256# Written by restic-setup, sourced by restic-backup, restic-restore and
257# restic-status. RESTIC_HOST is this machine's snapshot tag.
258export B2_ACCOUNT_ID="$NEW_ID"
259export B2_ACCOUNT_KEY="$NEW_KEY"
260export RESTIC_HOST="$NEW_HOST"
261ENV
262chmod 600 "$tmp"
263mv "$tmp" "$B2_ENV"
264
265tmp="$PW_FILE.tmp.$$"
266printf '%s' "$NEW_PW" > "$tmp"
267chmod 600 "$tmp"
268mv "$tmp" "$PW_FILE"
269
270echo ""
271echo "wrote $B2_ENV and $PW_FILE (0600)"
272
273if [ -n "$SUGGESTED" ]; then
274 echo ""
275 echo "the generated password, one last time, since this is the last screen"
276 echo "that will ever show it:"
277 echo ""
278 echo " $SUGGESTED"
279fi
280
281echo ""
282
283B2_ID=$NEW_ID
284B2_KEY=$NEW_KEY
285HOSTTAG=$NEW_HOST
286
287printf 'opening %s ... ' "$REPO"
288if repo_opens; then
289 echo "ok"
290 echo ""
291 echo "backups are set up. next:"
292 echo ""
293 echo " make backup take a snapshot from this machine"
294 echo " make restore pull everything back, on a fresh container"
295 echo " make snapshots last snapshot per host, and what the repo costs"
296 exit 0
297fi
298
299echo "FAILED"
300echo ""
301echo "the credentials are saved but the repository did not open. in order of"
302echo "how often it is the cause:"
303echo ""
304echo " the applicationKey is wrong, or was revoked in the B2 console"
305echo " the key is not scoped to the overshard-backups bucket"
306echo " the repo password is wrong, which a generated one will be if the"
307echo " repository already existed"
308echo " no network out of this container"
309echo ""
310echo "(it gives up after ${RESTIC_TIMEOUT}s. set RESTIC_TIMEOUT to wait longer.)"
311echo ""
312echo "the raw error, which usually names one of those:"
313echo ""
314restic_probe 2>&1 | sed 's/^/ /' | head -20 || true
315echo ""
316echo "if this is a brand new bucket with no repository in it yet, that is"
317echo "expected: restic-backup runs 'restic init' on its first run."
318exit 1