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.
@@ -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
+ }