frugal-iot-server 2.0.11 → 2.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/INSTALLATION.md +128 -31
- package/docs/OPENWRT.md +2 -2
- package/package.json +5 -3
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +15 -19
- package/scripts/addorganization.zsh +9 -3
- package/scripts/backup.zsh +361 -0
- package/scripts/broker-config.zsh +185 -0
- package/scripts/diagnostic.zsh +122 -83
- package/scripts/init.zsh +161 -84
- package/scripts/not-published.txt +1 -0
- package/scripts/openwrt-preflight.sh +14 -2
- package/scripts/restore.zsh +375 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env zsh
|
|
2
|
+
#
|
|
3
|
+
# Take a snapshot of this installation's STATE - everything that cannot be rebuilt from the
|
|
4
|
+
# package, a release or the database - as one archive. See BACKUP_PLAN.md for what is in it and why.
|
|
5
|
+
#
|
|
6
|
+
# Usage, from the server's own directory (or anywhere, once the frugaliot service is installed):
|
|
7
|
+
# npx --no frugal-iot-backup check # say what would be taken, from where; writes nothing
|
|
8
|
+
# npx --no frugal-iot-backup # take one, into /var/backups/frugal-iot
|
|
9
|
+
# npx --no frugal-iot-backup <dir> # take one into <dir> (an absolute path), pruning nothing there
|
|
10
|
+
# npx --no frugal-iot-backup install # run it nightly, from a systemd timer
|
|
11
|
+
#
|
|
12
|
+
# Positional, never --flags: npm swallows those (see "Shell scripts" in CLAUDE.md).
|
|
13
|
+
#
|
|
14
|
+
# Not in the snapshot:
|
|
15
|
+
# data/ large and append-only - mirrored by rsync instead (BACKUP_PLAN.md, "Two streams")
|
|
16
|
+
# ota/ rebuilt at every release
|
|
17
|
+
# anything mosquitto can rebuild or is running on: its plugin, retained messages, its log
|
|
18
|
+
#
|
|
19
|
+
# A snapshot holds every credential this server has - the db, secrets.yaml, the broker's password
|
|
20
|
+
# file, a TLS key - so it is mode 600, owned by the account the server runs as, in a directory
|
|
21
|
+
# nobody else can enter. Taking one needs root, because several of those are root- or
|
|
22
|
+
# mosquitto-only; run as anyone else it re-runs itself with sudo. "check" does not need root.
|
|
23
|
+
#
|
|
24
|
+
# The broker's files are found by asking its own configuration (scripts/broker-config.zsh), never
|
|
25
|
+
# assumed: production is laid out nothing like a Pi.
|
|
26
|
+
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
zmodload zsh/datetime
|
|
29
|
+
|
|
30
|
+
SCRIPT="${0:A}"
|
|
31
|
+
|
|
32
|
+
usage() {
|
|
33
|
+
sed -n '6,11p' "$SCRIPT" | sed 's/^# //' >&2
|
|
34
|
+
exit 2
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
MODE=backup
|
|
38
|
+
DEST=/var/backups/frugal-iot
|
|
39
|
+
PRUNE=1 # only the timer's own directory - see "prune" below
|
|
40
|
+
case "${1:-}" in
|
|
41
|
+
"") ;;
|
|
42
|
+
check) MODE=check ;;
|
|
43
|
+
install) MODE=install ;;
|
|
44
|
+
help|-h|--help) usage ;;
|
|
45
|
+
/*) DEST=$1; PRUNE=0 ;;
|
|
46
|
+
*) echo "Unexpected argument: $1" >&2; usage ;;
|
|
47
|
+
esac
|
|
48
|
+
(( $# <= 1 )) || usage
|
|
49
|
+
|
|
50
|
+
# How many to keep. Each tier keeps the newest snapshot in each of its most recent N periods that
|
|
51
|
+
# have one - so with the nightly timer: a week of dailies, a month of weeklies, three months of
|
|
52
|
+
# monthlies, a year of quarterlies, and one a year for ever. A snapshot may count in several tiers.
|
|
53
|
+
# Counting periods that HAVE a snapshot, rather than calendar periods, means a timer that stopped
|
|
54
|
+
# for a while does not get its last good snapshots pruned away when it starts again.
|
|
55
|
+
KEEP_DAILY=7
|
|
56
|
+
KEEP_WEEKLY=4
|
|
57
|
+
KEEP_MONTHLY=3
|
|
58
|
+
KEEP_QUARTERLY=4
|
|
59
|
+
KEEP_YEARLY=0 # 0 = no limit
|
|
60
|
+
|
|
61
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
62
|
+
die() { echo "Error: $*" >&2; exit 1; }
|
|
63
|
+
# Owner and mode of a file, spelled for Linux or BSD/macOS stat
|
|
64
|
+
ownmode() { stat -c '%U:%G %a' "$1" 2>/dev/null || stat -f '%Su:%Sg %Lp' "$1" 2>/dev/null || echo "?:? ?"; }
|
|
65
|
+
|
|
66
|
+
# ---- Where is the installation? ----
|
|
67
|
+
# The current directory if it holds the database, as for every other script here; otherwise wherever
|
|
68
|
+
# the frugaliot service runs - which is what lets the timer run this from anywhere.
|
|
69
|
+
SERVICE_UNIT=""
|
|
70
|
+
if have systemctl; then
|
|
71
|
+
SERVICE_UNIT=$(systemctl show -p FragmentPath --value frugaliot 2>/dev/null) || true
|
|
72
|
+
fi
|
|
73
|
+
if [[ -f ./frugal-iot.db ]]; then
|
|
74
|
+
INSTALL_DIR=$PWD
|
|
75
|
+
else
|
|
76
|
+
INSTALL_DIR=""
|
|
77
|
+
if have systemctl; then
|
|
78
|
+
INSTALL_DIR=$(systemctl show -p WorkingDirectory --value frugaliot 2>/dev/null) || true
|
|
79
|
+
fi
|
|
80
|
+
[[ -n "$INSTALL_DIR" && -f "$INSTALL_DIR/frugal-iot.db" ]] ||
|
|
81
|
+
die "no frugal-iot.db here, and no frugaliot service saying where one is - run this from the server's own directory"
|
|
82
|
+
fi
|
|
83
|
+
cd "$INSTALL_DIR"
|
|
84
|
+
have sqlite3 || die "sqlite3 command not found - install it with: sudo apt install sqlite3"
|
|
85
|
+
|
|
86
|
+
# The account the server runs as owns the database, and will own the snapshots - so the offsite pull
|
|
87
|
+
# can log in as that account and needs nothing more. Not $USER: under sudo or the timer that is root.
|
|
88
|
+
# Quoted: unquoted, the substitution is split into words and the %% applies to each - "mitra 644"
|
|
89
|
+
SERVICE_USER=${"$(ownmode frugal-iot.db)"%%:*}
|
|
90
|
+
# The machine's own name, for telling snapshots apart once they are all on one laptop. Not $HOST,
|
|
91
|
+
# which zsh ties to the real hostname and is not ours to assign (see CLAUDE.md).
|
|
92
|
+
SNAP_HOST=$(hostname -s 2>/dev/null || hostname)
|
|
93
|
+
|
|
94
|
+
# ---- What would be taken ----
|
|
95
|
+
# Parallel arrays, one entry per file or directory:
|
|
96
|
+
# ITEM_SRC absolute path it comes from
|
|
97
|
+
# ITEM_DST path inside the archive: install/... for this installation, fs/<absolute path> otherwise
|
|
98
|
+
# ITEM_ROLE what it is: install, service, config (a broker configuration file), or for a file the
|
|
99
|
+
# broker's configuration names, the setting that names it - password_file, keyfile... -
|
|
100
|
+
# because that is what lets a restore put it wherever ANOTHER machine's broker keeps it
|
|
101
|
+
ITEM_SRC=(); ITEM_DST=(); ITEM_ROLE=()
|
|
102
|
+
typeset -A TAKEN
|
|
103
|
+
add_item() { # role src dst
|
|
104
|
+
[[ -n "${TAKEN[$2]:-}" ]] && return 0
|
|
105
|
+
TAKEN[$2]=1
|
|
106
|
+
ITEM_ROLE+=("$1"); ITEM_SRC+=("$2"); ITEM_DST+=("$3")
|
|
107
|
+
}
|
|
108
|
+
add_item install "$INSTALL_DIR/frugal-iot.db" install/frugal-iot.db
|
|
109
|
+
add_item install "$INSTALL_DIR/config.yaml" install/config.yaml
|
|
110
|
+
add_item install "$INSTALL_DIR/config.d" install/config.d
|
|
111
|
+
[[ -n "$SERVICE_UNIT" && -e "$SERVICE_UNIT" ]] && add_item service "$SERVICE_UNIT" "fs$SERVICE_UNIT"
|
|
112
|
+
|
|
113
|
+
source "${SCRIPT:h}/broker-config.zsh"
|
|
114
|
+
broker_conf_read
|
|
115
|
+
for f in $BROKER_CONF_FILES; do add_item config "$f" "fs$f"; done
|
|
116
|
+
SKIPPED=()
|
|
117
|
+
while read -r setting named role; do
|
|
118
|
+
[[ -n "${named:-}" ]] || continue
|
|
119
|
+
case $role in
|
|
120
|
+
backup|tls) add_item "$setting" "$named" "fs$named" ;;
|
|
121
|
+
# Every account in it is rebuilt from the db - except the admin one the rebuild logs in AS,
|
|
122
|
+
# whose password is in secrets.yaml. So a restore puts this back alongside secrets.yaml, and
|
|
123
|
+
# the two agree; restoring secrets.yaml alone onto another broker locks the server out of it.
|
|
124
|
+
rebuildable) add_item "$setting" "$named" "fs$named" ;;
|
|
125
|
+
*) SKIPPED+=("$setting $named ($role)") ;;
|
|
126
|
+
esac
|
|
127
|
+
done < <(broker_named_files)
|
|
128
|
+
|
|
129
|
+
# ---- Root, when it is needed ----
|
|
130
|
+
# Installing the timer always needs it. Taking a snapshot needs it whenever anything to be taken is
|
|
131
|
+
# not readable as we are - on any machine with a broker, that is its password file at least.
|
|
132
|
+
as_root() {
|
|
133
|
+
have sudo || die "this needs root, and there is no sudo here - run it as root"
|
|
134
|
+
echo "Re-running as root, which can read the broker's files..." >&2
|
|
135
|
+
exec sudo -- zsh "$SCRIPT" "$@"
|
|
136
|
+
}
|
|
137
|
+
if (( EUID != 0 )); then
|
|
138
|
+
if [[ $MODE == install ]]; then
|
|
139
|
+
as_root "$@"
|
|
140
|
+
elif [[ $MODE == backup ]]; then
|
|
141
|
+
(( ${#BROKER_CONF_UNREADABLE} )) && as_root "$@"
|
|
142
|
+
for f in $ITEM_SRC; do
|
|
143
|
+
[[ ! -e "$f" || -r "$f" ]] || as_root "$@"
|
|
144
|
+
done
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
# Can each be read, and does it exist at all? A root-only file is only a problem if we are root and
|
|
149
|
+
# still cannot see it; in "check" as an ordinary user it is exactly what the timer, as root, will take.
|
|
150
|
+
PROBLEMS=()
|
|
151
|
+
item_state() { # src -> "ok", "needs root" or "MISSING"
|
|
152
|
+
if [[ -r "$1" ]]; then echo ok
|
|
153
|
+
elif [[ -e "$1" ]] || { (( EUID != 0 )) && have sudo && sudo -n test -e "$1" 2>/dev/null; }; then echo "needs root"
|
|
154
|
+
elif (( EUID != 0 )) && have sudo && ! sudo -n true 2>/dev/null; then echo "cannot tell without sudo"
|
|
155
|
+
else echo MISSING
|
|
156
|
+
fi
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# ---- check ----
|
|
160
|
+
if [[ $MODE == check ]]; then
|
|
161
|
+
echo "Installation: $INSTALL_DIR (runs as $SERVICE_USER)"
|
|
162
|
+
echo "Snapshots go: $DEST"
|
|
163
|
+
echo
|
|
164
|
+
echo "Would take:"
|
|
165
|
+
for i in {1..${#ITEM_SRC}}; do
|
|
166
|
+
st=$(item_state "$ITEM_SRC[$i]")
|
|
167
|
+
if [[ $st == ok ]]; then
|
|
168
|
+
size=$(du -sh "$ITEM_SRC[$i]" 2>/dev/null | awk '{print $1}') || true
|
|
169
|
+
st="ok, ${size:-?}, $(ownmode "$ITEM_SRC[$i]")"
|
|
170
|
+
fi
|
|
171
|
+
printf ' %-22s %s: %s\n' "$ITEM_ROLE[$i]" "$ITEM_SRC[$i]" "$st"
|
|
172
|
+
[[ $st == MISSING ]] && PROBLEMS+=("$ITEM_SRC[$i] is named by the configuration but does not exist")
|
|
173
|
+
done
|
|
174
|
+
(( ${#BROKER_CONF_FILES} )) || echo " (no mosquitto configuration at $(broker_conf_main) - nothing of the broker's to take)"
|
|
175
|
+
if (( ${#BROKER_CONF_UNREADABLE} )); then
|
|
176
|
+
echo " NOT READABLE without sudo: ${(j:, :)BROKER_CONF_UNREADABLE} - files it names are missing above"
|
|
177
|
+
fi
|
|
178
|
+
if (( ${#SKIPPED} )); then
|
|
179
|
+
echo
|
|
180
|
+
echo "Named by the broker's configuration, deliberately not taken:"
|
|
181
|
+
for s in $SKIPPED; do echo " $s"; done
|
|
182
|
+
fi
|
|
183
|
+
echo
|
|
184
|
+
echo "Not taken: data/ (mirrored separately) and ota/ (rebuilt each release)."
|
|
185
|
+
echo
|
|
186
|
+
if [[ -d "$DEST" ]] || { have sudo && sudo -n test -d "$DEST" 2>/dev/null; }; then
|
|
187
|
+
SNAPS=$( (ls -1 "$DEST" 2>/dev/null || sudo -n ls -1 "$DEST" 2>/dev/null) | grep -c '^frugal-iot-state-.*\.tar\.gz$') || true
|
|
188
|
+
echo "Existing snapshots in $DEST: ${SNAPS:-0} ($(ownmode "$DEST"))"
|
|
189
|
+
else
|
|
190
|
+
echo "$DEST does not exist yet - the first snapshot creates it"
|
|
191
|
+
fi
|
|
192
|
+
df -Pk "${DEST:h}" 2>/dev/null | awk 'NR==2 {printf "Free space there: %d MB\n", $4/1024}' || true
|
|
193
|
+
if have systemctl; then
|
|
194
|
+
# is-enabled prints "not-found" AND fails for a unit that does not exist, so test its answer,
|
|
195
|
+
# not its exit status - or both get printed
|
|
196
|
+
TIMER_STATE=$(systemctl is-enabled frugal-iot-backup.timer 2>/dev/null) || true
|
|
197
|
+
[[ -z "$TIMER_STATE" || "$TIMER_STATE" == not-found ]] && TIMER_STATE="not installed - npx --no frugal-iot-backup install"
|
|
198
|
+
echo "Timer: $TIMER_STATE"
|
|
199
|
+
fi
|
|
200
|
+
if (( ${#PROBLEMS} )); then
|
|
201
|
+
echo
|
|
202
|
+
for p in $PROBLEMS; do echo "PROBLEM: $p"; done
|
|
203
|
+
exit 1
|
|
204
|
+
fi
|
|
205
|
+
exit 0
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
# ---- install: a nightly systemd timer ----
|
|
209
|
+
# Written here rather than shipped in extras/, because the paths in it are this machine's: where the
|
|
210
|
+
# installation is, where zsh is. A changed extras/ file never reaches /etc on its own anyway (see
|
|
211
|
+
# CLAUDE.md), so generating it is also what keeps a re-run current.
|
|
212
|
+
if [[ $MODE == install ]]; then
|
|
213
|
+
have systemctl || die "no systemd here - run '$SCRIPT' nightly from cron instead"
|
|
214
|
+
TMPDIR_UNITS=$(mktemp -d)
|
|
215
|
+
trap 'rm -rf "$TMPDIR_UNITS"' EXIT
|
|
216
|
+
cat > "$TMPDIR_UNITS/frugal-iot-backup.service" <<EOF
|
|
217
|
+
# Written by frugal-iot-backup install - re-run that rather than editing this.
|
|
218
|
+
[Unit]
|
|
219
|
+
Description=Frugal IoT state snapshot (frugal-iot-backup)
|
|
220
|
+
After=frugaliot.service
|
|
221
|
+
|
|
222
|
+
[Service]
|
|
223
|
+
Type=oneshot
|
|
224
|
+
WorkingDirectory=${INSTALL_DIR}
|
|
225
|
+
ExecStart=$(command -v zsh) ${SCRIPT}
|
|
226
|
+
# Root, which is the point: it reads the broker's root- and mosquitto-only files
|
|
227
|
+
Nice=10
|
|
228
|
+
IOSchedulingClass=idle
|
|
229
|
+
EOF
|
|
230
|
+
cat > "$TMPDIR_UNITS/frugal-iot-backup.timer" <<EOF
|
|
231
|
+
# Written by frugal-iot-backup install - re-run that rather than editing this.
|
|
232
|
+
[Unit]
|
|
233
|
+
Description=Nightly Frugal IoT state snapshot
|
|
234
|
+
|
|
235
|
+
[Timer]
|
|
236
|
+
OnCalendar=*-*-* 03:17:00
|
|
237
|
+
RandomizedDelaySec=20min
|
|
238
|
+
# A Pi switched off at 03:17 takes the missed one when it next boots
|
|
239
|
+
Persistent=true
|
|
240
|
+
|
|
241
|
+
[Install]
|
|
242
|
+
WantedBy=timers.target
|
|
243
|
+
EOF
|
|
244
|
+
CHANGED=0
|
|
245
|
+
for u in frugal-iot-backup.service frugal-iot-backup.timer; do
|
|
246
|
+
if cmp -s "$TMPDIR_UNITS/$u" "/etc/systemd/system/$u" 2>/dev/null; then
|
|
247
|
+
echo " kept /etc/systemd/system/$u (already current)"
|
|
248
|
+
else
|
|
249
|
+
install -o root -g root -m 644 "$TMPDIR_UNITS/$u" "/etc/systemd/system/$u"
|
|
250
|
+
echo " wrote /etc/systemd/system/$u"
|
|
251
|
+
CHANGED=1
|
|
252
|
+
fi
|
|
253
|
+
done
|
|
254
|
+
(( CHANGED )) && systemctl daemon-reload
|
|
255
|
+
systemctl enable --now frugal-iot-backup.timer
|
|
256
|
+
systemctl list-timers frugal-iot-backup.timer --no-pager || true
|
|
257
|
+
echo
|
|
258
|
+
echo "Take the first one now, rather than waiting for tonight:"
|
|
259
|
+
echo " sudo systemctl start frugal-iot-backup && journalctl -u frugal-iot-backup -n 20 --no-pager"
|
|
260
|
+
exit 0
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
# ---- backup ----
|
|
264
|
+
umask 077
|
|
265
|
+
for i in {1..${#ITEM_SRC}}; do
|
|
266
|
+
[[ -e "$ITEM_SRC[$i]" ]] || die "$ITEM_SRC[$i] ($ITEM_ROLE[$i]) does not exist - run 'frugal-iot-backup check'"
|
|
267
|
+
done
|
|
268
|
+
(( ${#BROKER_CONF_UNREADABLE} == 0 )) || die "could not read ${(j:, :)BROKER_CONF_UNREADABLE}, even as root"
|
|
269
|
+
|
|
270
|
+
mkdir -p "$DEST"
|
|
271
|
+
chown "$SERVICE_USER" "$DEST"
|
|
272
|
+
chmod 700 "$DEST"
|
|
273
|
+
|
|
274
|
+
STAMP=$(strftime '%Y%m%d-%H%M%S' $EPOCHSECONDS)
|
|
275
|
+
NAME="frugal-iot-state-${SNAP_HOST}-${STAMP}.tar.gz"
|
|
276
|
+
# Built beside the destination and renamed into place, so a snapshot that exists is a whole one -
|
|
277
|
+
# a run killed part-way leaves only the hidden staging directory, removed on exit
|
|
278
|
+
STAGE=$(mktemp -d "$DEST/.staging.XXXXXX")
|
|
279
|
+
trap 'rm -rf "$STAGE"' EXIT
|
|
280
|
+
SNAP="$STAGE/snap"
|
|
281
|
+
mkdir -p "$SNAP/install"
|
|
282
|
+
|
|
283
|
+
for i in {1..${#ITEM_SRC}}; do
|
|
284
|
+
src=$ITEM_SRC[$i]; dst="$SNAP/$ITEM_DST[$i]"
|
|
285
|
+
mkdir -p "${dst:h}"
|
|
286
|
+
if [[ $ITEM_DST[$i] == install/frugal-iot.db ]]; then
|
|
287
|
+
# Never cp a database the server is writing to: .backup takes a consistent copy while it runs
|
|
288
|
+
sqlite3 "$src" ".backup '${dst}'"
|
|
289
|
+
INTEGRITY=$(sqlite3 "$dst" 'PRAGMA integrity_check;') || true
|
|
290
|
+
[[ "$INTEGRITY" == ok ]] || die "the copied database fails its integrity check: ${INTEGRITY:-no answer}"
|
|
291
|
+
else
|
|
292
|
+
cp -Rp -- "$src" "$dst"
|
|
293
|
+
fi
|
|
294
|
+
done
|
|
295
|
+
|
|
296
|
+
# What a restore needs to know that the archive does not say by itself: where each thing came from
|
|
297
|
+
# and who owned it - a laptop extracting this as an ordinary user loses the owners.
|
|
298
|
+
{
|
|
299
|
+
echo "# Frugal IoT state snapshot - see BACKUP_PLAN.md"
|
|
300
|
+
# 2: the role of a broker file is the setting that named it. frugal-iot-restore refuses others.
|
|
301
|
+
echo "format 2"
|
|
302
|
+
echo "created $(strftime '%Y-%m-%dT%H:%M:%S%z' $EPOCHSECONDS)"
|
|
303
|
+
echo "host $(hostname 2>/dev/null)"
|
|
304
|
+
echo "install_dir $INSTALL_DIR"
|
|
305
|
+
echo "service_user $SERVICE_USER"
|
|
306
|
+
for p in frugal-iot-server frugal-iot-client frugal-iot-logger; do
|
|
307
|
+
v=$(node -e "console.log(require('./node_modules/$p/package.json').version)" 2>/dev/null) || true
|
|
308
|
+
echo "$p ${v:-not installed here}"
|
|
309
|
+
done
|
|
310
|
+
v=""
|
|
311
|
+
have mosquitto && { v=$( (set +o pipefail; mosquitto -h 2>&1 | head -1) ) || true; }
|
|
312
|
+
echo "mosquitto ${v:-not installed here}"
|
|
313
|
+
echo "broker_conf $(broker_conf_main)"
|
|
314
|
+
echo "tables $(sqlite3 "$SNAP/install/frugal-iot.db" "SELECT group_concat(name, ' ') FROM sqlite_master WHERE type='table';")"
|
|
315
|
+
echo "# file role owner:group mode from in archive"
|
|
316
|
+
for i in {1..${#ITEM_SRC}}; do
|
|
317
|
+
echo "file $ITEM_ROLE[$i] $(ownmode "$ITEM_SRC[$i]") $ITEM_SRC[$i] $ITEM_DST[$i]"
|
|
318
|
+
done
|
|
319
|
+
for s in $SKIPPED; do echo "skipped $s"; done
|
|
320
|
+
} > "$SNAP/MANIFEST"
|
|
321
|
+
|
|
322
|
+
tar -czf "$STAGE/$NAME" -C "$SNAP" .
|
|
323
|
+
# Read it back before trusting it: a truncated archive on a full disk otherwise looks like a backup
|
|
324
|
+
tar -tzf "$STAGE/$NAME" >/dev/null || die "the archive just written cannot be read back"
|
|
325
|
+
chown "$SERVICE_USER" "$STAGE/$NAME"
|
|
326
|
+
chmod 600 "$STAGE/$NAME"
|
|
327
|
+
mv "$STAGE/$NAME" "$DEST/$NAME"
|
|
328
|
+
echo "wrote $DEST/$NAME ($(du -h "$DEST/$NAME" | awk '{print $1}'), ${#ITEM_SRC} items)"
|
|
329
|
+
|
|
330
|
+
# ---- prune ----
|
|
331
|
+
# Only in the default directory: one named on the command line is the caller's. frugal-iot-restore
|
|
332
|
+
# keeps its undo points in pre-restore/, and pruning there to one a day deleted the previous undo
|
|
333
|
+
# point - the snapshot a second restore that day was being asked to put back.
|
|
334
|
+
(( PRUNE )) || exit 0
|
|
335
|
+
# Only this machine's snapshots, by name - so a directory that also holds copies pulled from
|
|
336
|
+
# elsewhere, or anything else at all, is left alone. Newest first; names sort by time.
|
|
337
|
+
typeset -A SEEN COUNT
|
|
338
|
+
# tier <name> <period> <limit>: keep this snapshot if it is the first (so newest) seen in its period,
|
|
339
|
+
# and that tier has not yet filled its limit of periods
|
|
340
|
+
tier() {
|
|
341
|
+
[[ -z "${SEEN[$1:$2]:-}" ]] || return 0
|
|
342
|
+
(( $3 == 0 || ${COUNT[$1]:-0} < $3 )) || return 0
|
|
343
|
+
SEEN[$1:$2]=1
|
|
344
|
+
COUNT[$1]=$(( ${COUNT[$1]:-0} + 1 ))
|
|
345
|
+
keep=1
|
|
346
|
+
}
|
|
347
|
+
for f in "$DEST"/frugal-iot-state-${SNAP_HOST}-<->-<->.tar.gz(N.On); do
|
|
348
|
+
stamp=${${f:t}#frugal-iot-state-${SNAP_HOST}-}
|
|
349
|
+
day=${stamp[1,8]}
|
|
350
|
+
strftime -r -s secs '%Y%m%d' "$day" 2>/dev/null || continue
|
|
351
|
+
keep=0
|
|
352
|
+
tier daily "$day" $KEEP_DAILY
|
|
353
|
+
tier weekly $(( secs / 604800 )) $KEEP_WEEKLY
|
|
354
|
+
tier monthly "${day[1,6]}" $KEEP_MONTHLY
|
|
355
|
+
tier quarterly "${day[1,4]}q$(( (${day[5,6]} - 1) / 3 + 1 ))" $KEEP_QUARTERLY
|
|
356
|
+
tier yearly "${day[1,4]}" $KEEP_YEARLY
|
|
357
|
+
if (( ! keep )); then
|
|
358
|
+
rm -f -- "$f"
|
|
359
|
+
echo "pruned ${f:t}"
|
|
360
|
+
fi
|
|
361
|
+
done
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Where this machine's mosquitto keeps its configuration and files - asked of the broker's own
|
|
2
|
+
# configuration, never assumed. Sourced, not run:
|
|
3
|
+
#
|
|
4
|
+
# source "${0:A:h}/broker-config.zsh" # ${0:A} resolves the node_modules/.bin symlink
|
|
5
|
+
# broker_conf_read
|
|
6
|
+
# broker_setting password_file; PWFILE="$BROKER_SETTING"
|
|
7
|
+
#
|
|
8
|
+
# Machines are not laid out alike. A Pi from install-pi.sh has conf.d/frugal-iot.conf and its
|
|
9
|
+
# password file in /var/lib/mosquitto; production was assembled by hand as several files in conf.d
|
|
10
|
+
# with the password file in /etc/mosquitto; OpenWrt writes its own mosquitto.conf. Three scripts each
|
|
11
|
+
# had a copy of this, each slightly different, and the one thing they all got wrong is below.
|
|
12
|
+
#
|
|
13
|
+
# A conf.d file can be readable by root only - a Pi's bridge configuration holds a password, so it
|
|
14
|
+
# should be. Reading all the files in one grep or cat then gets the readable ones and silently skips
|
|
15
|
+
# that one, which is not empty and so never triggered the sudo retry - and a setting in the skipped
|
|
16
|
+
# file was reported as whatever an earlier file said. So each file is read on its own, with sudo when
|
|
17
|
+
# it cannot be read plainly, and a file that still cannot be read is listed in
|
|
18
|
+
# BROKER_CONF_UNREADABLE so the caller can say the answer is incomplete instead of being wrong.
|
|
19
|
+
#
|
|
20
|
+
# Set before broker_conf_read, if the defaults do not suit:
|
|
21
|
+
# BROKER_CONF the main configuration file. Default: the -c of the running service, else
|
|
22
|
+
# /etc/mosquitto/mosquitto.conf. Also how the tests point this at a fixture.
|
|
23
|
+
# BROKER_SUDO how to retry an unreadable file. Default (sudo -n), which never prompts - right for
|
|
24
|
+
# the diagnostic's quiet mode; a script that may prompt sets (sudo). Unused as root.
|
|
25
|
+
#
|
|
26
|
+
# Results, after broker_conf_read:
|
|
27
|
+
# BROKER_CONF_FILES every file the broker reads, in the order it reads them
|
|
28
|
+
# BROKER_CONF_UNREADABLE those of them that could not be read even with BROKER_SUDO
|
|
29
|
+
# BROKER_CONF_TEXT their contents, comments and blank lines removed, in order
|
|
30
|
+
|
|
31
|
+
: ${BROKER_CONF:=}
|
|
32
|
+
(( ${+BROKER_SUDO} )) || BROKER_SUDO=(sudo -n)
|
|
33
|
+
BROKER_CONF_FILES=()
|
|
34
|
+
BROKER_CONF_UNREADABLE=()
|
|
35
|
+
BROKER_CONF_TEXT=""
|
|
36
|
+
BROKER_SETTING=""
|
|
37
|
+
|
|
38
|
+
# The main configuration file. mosquitto has no default of its own - it reads nothing without -c -
|
|
39
|
+
# so the service's command line is the authority, and /etc/mosquitto/mosquitto.conf only the
|
|
40
|
+
# fallback when there is no systemd to ask (OpenWrt) or the service is not installed.
|
|
41
|
+
broker_conf_main() {
|
|
42
|
+
local exec_start
|
|
43
|
+
if [[ -n "$BROKER_CONF" ]]; then print -r -- "$BROKER_CONF"; return; fi
|
|
44
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
45
|
+
exec_start=$(systemctl show -p ExecStart mosquitto 2>/dev/null) || true
|
|
46
|
+
if [[ "$exec_start" =~ ' -c ([^ ;]+)' ]]; then print -r -- "${match[1]}"; return; fi
|
|
47
|
+
fi
|
|
48
|
+
print -r -- /etc/mosquitto/mosquitto.conf
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# One file's contents, with sudo if it cannot be read plainly. Fails if neither works.
|
|
52
|
+
broker_conf_cat() {
|
|
53
|
+
local f=$1
|
|
54
|
+
if [[ -r "$f" ]]; then
|
|
55
|
+
cat -- "$f" 2>/dev/null
|
|
56
|
+
elif (( EUID != 0 && ${#BROKER_SUDO} )) && command -v "${BROKER_SUDO[1]}" >/dev/null 2>&1; then
|
|
57
|
+
"${BROKER_SUDO[@]}" cat -- "$f" 2>/dev/null
|
|
58
|
+
else
|
|
59
|
+
return 1
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
broker_conf_read() {
|
|
64
|
+
local main dir f t
|
|
65
|
+
local -a dirs
|
|
66
|
+
BROKER_CONF_FILES=(); BROKER_CONF_UNREADABLE=(); BROKER_CONF_TEXT=""
|
|
67
|
+
main=$(broker_conf_main)
|
|
68
|
+
[[ -e "$main" ]] || return 0
|
|
69
|
+
BROKER_CONF_FILES=("$main")
|
|
70
|
+
# include_dir is only honoured in the main file, and mosquitto loads a directory's *.conf files in
|
|
71
|
+
# case-sensitive alphabetical order - hence the C collation. Files not ending .conf are ignored by
|
|
72
|
+
# the broker, so a stray frugal-iot.conf.bak is ignored here too.
|
|
73
|
+
t=$(broker_conf_cat "$main") || { BROKER_CONF_UNREADABLE=("$main"); return 0; }
|
|
74
|
+
BROKER_CONF_TEXT=$(print -r -- "$t" | grep -vE '^[[:space:]]*(#|$)') || true
|
|
75
|
+
dirs=(${(f)"$(print -r -- "$BROKER_CONF_TEXT" | awk '$1 == "include_dir" {print $2}')"})
|
|
76
|
+
for dir in $dirs; do
|
|
77
|
+
BROKER_CONF_FILES+=(${(f)"$(LC_COLLATE=C; print -rl -- $dir/*.conf(N))"})
|
|
78
|
+
done
|
|
79
|
+
for f in ${BROKER_CONF_FILES[2,-1]}; do
|
|
80
|
+
if t=$(broker_conf_cat "$f"); then
|
|
81
|
+
# "|| true": a file of nothing but comments makes grep fail, which under the callers'
|
|
82
|
+
# "set -e -o pipefail" would end them silently
|
|
83
|
+
t=$(print -r -- "$t" | grep -vE '^[[:space:]]*(#|$)') || true
|
|
84
|
+
BROKER_CONF_TEXT+=$'\n'"$t"
|
|
85
|
+
else
|
|
86
|
+
BROKER_CONF_UNREADABLE+=("$f")
|
|
87
|
+
fi
|
|
88
|
+
done
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Every configuration line whose first word matches a pattern, in the order the broker reads them:
|
|
92
|
+
# broker_conf_lines 'connection|address'
|
|
93
|
+
broker_conf_lines() {
|
|
94
|
+
print -r -- "$BROKER_CONF_TEXT" | grep -E "^[[:space:]]*($1)([[:space:]]|$)" || true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# The value of a setting into BROKER_SETTING - a variable rather than output, so that it can be
|
|
98
|
+
# called without a subshell. The last occurrence wins, as it does in mosquitto, for the settings
|
|
99
|
+
# where that is true (password_file, acl_file, plugin...); for per-listener ones it is only the last
|
|
100
|
+
# listener's, and broker_conf_lines is the thing to use.
|
|
101
|
+
broker_setting() {
|
|
102
|
+
BROKER_SETTING=$(broker_conf_lines "$1" | tail -1 | awk '{print $2}') || true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Files the configuration names, one "setting path role" per line, wherever this machine keeps them.
|
|
106
|
+
# The role is what a backup does with it - see BACKUP_PLAN.md:
|
|
107
|
+
# backup not recoverable any other way, so taken and put back on restore
|
|
108
|
+
# tls taken: a certificate can be re-issued, but production's were placed by hand, and
|
|
109
|
+
# restoring them saves every bridge an outage. keyfile is a secret like the db.
|
|
110
|
+
# rebuildable frugal-iot-rebuild-dynsec recreates its accounts from the db - all but the admin
|
|
111
|
+
# one it logs in as, whose password is in secrets.yaml, so it is taken all the same
|
|
112
|
+
# package part of the mosquitto install, not of this installation
|
|
113
|
+
# live the broker's running state or its log - stale the moment it is copied
|
|
114
|
+
broker_named_files() {
|
|
115
|
+
broker_conf_lines 'password_file|acl_file' | awk '{print $1, $2, "backup"}'
|
|
116
|
+
broker_conf_lines 'certfile|keyfile|cafile|capath|dhparamfile|crlfile' | awk '{print $1, $2, "tls"}'
|
|
117
|
+
broker_conf_lines 'plugin_opt_config_file' | awk '{print $1, $2, "rebuildable"}'
|
|
118
|
+
broker_conf_lines 'plugin' | awk '{print $1, $2, "package"}'
|
|
119
|
+
broker_conf_lines 'persistence_location' | awk '{print $1, $2, "live"}'
|
|
120
|
+
broker_conf_lines 'log_dest' | awk '$2 == "file" {print "log_dest", $3, "live"}'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Compare a shipped configuration (extras/mosquitto.conf) with what this broker actually reads,
|
|
124
|
+
# setting by setting - not file against file. Production's configuration is several hand-written
|
|
125
|
+
# files in conf.d and a Pi's is one frugal-iot.conf, and the same settings in a different file, a
|
|
126
|
+
# different order or with different comments are the same configuration. One finding per line:
|
|
127
|
+
#
|
|
128
|
+
# missing<TAB>text the release sets something this broker does not set at all
|
|
129
|
+
# differs<TAB>text both set it, to different values
|
|
130
|
+
# elsewhere<TAB>text a file the release names at one path and this broker at another - normal,
|
|
131
|
+
# and only information: a hand-built broker keeps its files where it likes
|
|
132
|
+
#
|
|
133
|
+
# Nothing printed means nothing to say. Three kinds of setting, because they differ in what counts:
|
|
134
|
+
# - listener and protocol: a listener is a port, and the protocol line after it belongs to it -
|
|
135
|
+
# so "listener 1883 0.0.0.0" is the release's "listener 1883", and its protocol is compared there
|
|
136
|
+
# - a setting whose value is a path (or the installer's PLUGIN_PATH placeholder): only that it is
|
|
137
|
+
# set; where is this machine's business
|
|
138
|
+
# - anything else: the value, last one read winning, as in mosquitto
|
|
139
|
+
broker_conf_compare() {
|
|
140
|
+
local shipped=$1 line d v port
|
|
141
|
+
local -a w
|
|
142
|
+
local -A ship_proto here_proto ship_val here_val
|
|
143
|
+
local -a ship_order
|
|
144
|
+
local -A seen
|
|
145
|
+
# Shipped side
|
|
146
|
+
port=""
|
|
147
|
+
for line in ${(f)"$(grep -vE '^[[:space:]]*(#|$)' "$shipped" 2>/dev/null)"}; do
|
|
148
|
+
w=(${=line}); d=$w[1]; v="${w[2,-1]}"
|
|
149
|
+
case $d in
|
|
150
|
+
listener) port=$w[2]; ship_proto[$port]=mqtt ;;
|
|
151
|
+
protocol) [[ -n "$port" ]] && ship_proto[$port]=$w[2] ;;
|
|
152
|
+
*) ship_val[$d]=$v; (( ${+seen[$d]} )) || { seen[$d]=1; ship_order+=("$d"); } ;;
|
|
153
|
+
esac
|
|
154
|
+
done
|
|
155
|
+
# This broker's side, in the order it reads its files
|
|
156
|
+
port=""
|
|
157
|
+
for line in ${(f)BROKER_CONF_TEXT}; do
|
|
158
|
+
w=(${=line}); d=${w[1]:-}; v="${w[2,-1]}"
|
|
159
|
+
[[ -n "$d" ]] || continue
|
|
160
|
+
case $d in
|
|
161
|
+
listener) port=$w[2]; here_proto[$port]=mqtt ;;
|
|
162
|
+
protocol) [[ -n "$port" ]] && here_proto[$port]=$w[2] ;;
|
|
163
|
+
*) here_val[$d]=$v ;;
|
|
164
|
+
esac
|
|
165
|
+
done
|
|
166
|
+
for port in ${(ok)ship_proto}; do
|
|
167
|
+
if (( ! ${+here_proto[$port]} )); then
|
|
168
|
+
print -r -- "missing listener $port ($ship_proto[$port]) - not declared"
|
|
169
|
+
elif [[ $here_proto[$port] != $ship_proto[$port] ]]; then
|
|
170
|
+
print -r -- "differs listener $port - this release speaks $ship_proto[$port] on it, this broker $here_proto[$port]"
|
|
171
|
+
fi
|
|
172
|
+
done
|
|
173
|
+
for d in $ship_order; do
|
|
174
|
+
v=$ship_val[$d]
|
|
175
|
+
if (( ! ${+here_val[$d]} )); then
|
|
176
|
+
print -r -- "missing $d $v - not set (mosquitto's default applies)"
|
|
177
|
+
elif [[ $v == /* || $v == *PLUGIN_PATH* ]]; then
|
|
178
|
+
[[ $here_val[$d] == $v || $v == *PLUGIN_PATH* ]] ||
|
|
179
|
+
print -r -- "elsewhere $d - here $here_val[$d] (this release's default is $v)"
|
|
180
|
+
elif [[ $here_val[$d] != $v ]]; then
|
|
181
|
+
print -r -- "differs $d - this release sets $v, this broker $here_val[$d]"
|
|
182
|
+
fi
|
|
183
|
+
done
|
|
184
|
+
return 0 # callers run under "set -e"; a comparison that found nothing has not failed
|
|
185
|
+
}
|