frugal-iot-server 2.0.10 → 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.
@@ -133,36 +133,59 @@ if [[ -f frugal-iot.db ]] && have sqlite3; then
133
133
  fi
134
134
 
135
135
  section "Mosquitto configuration"
136
- if [[ -f /etc/mosquitto/mosquitto.conf ]]; then
137
- item "/etc/mosquitto/mosquitto.conf: $(fileinfo /etc/mosquitto/mosquitto.conf)"
136
+ # Which files the broker reads, asked of its own configuration - production's is several hand-made
137
+ # files in conf.d, a Pi's is one frugal-iot.conf. scripts/broker-config.zsh explains the sudo retry,
138
+ # which is per file: a root-only bridge file used to be skipped silently among readable ones.
139
+ # docs/INSTALLATION.md has this script copied on its own to a machine not yet installed, so the
140
+ # helper may not be beside it - look in the installed package too before giving up.
141
+ BROKER_HELPER=""
142
+ for f in "${0:A:h}/broker-config.zsh" node_modules/frugal-iot-server/scripts/broker-config.zsh; do
143
+ [[ -r "$f" ]] && { BROKER_HELPER="$f"; break; }
144
+ done
145
+ if [[ -z "$BROKER_HELPER" ]]; then
146
+ print -u3 "diagnostic.zsh needs broker-config.zsh beside it - copy that from the same scripts/ directory"
147
+ exit 2
148
+ fi
149
+ source "$BROKER_HELPER"
150
+ broker_conf_read
151
+ if (( ! ${#BROKER_CONF_FILES} )); then
152
+ item "$(broker_conf_main): missing - is mosquitto installed?"
138
153
  else
139
- item "/etc/mosquitto/mosquitto.conf: missing - is mosquitto installed?"
140
- fi
141
- item "files in /etc/mosquitto/conf.d:"
142
- for f in /etc/mosquitto/conf.d/*(N); do
143
- item " ${f}: $(fileinfo $f)"
144
- grep -nE '^[[:space:]]*(listener|protocol|password_file|allow_anonymous)' $f 2>/dev/null | sed 's/^/ /'
154
+ item "files the broker reads, in order:"
155
+ for f in $BROKER_CONF_FILES; do
156
+ item " ${f}: $(fileinfo $f)"
157
+ # Read through the helper rather than grep, so a root-only file is shown as well
158
+ print -r -- "$(broker_conf_cat $f 2>/dev/null)" | grep -nE '^[[:space:]]*(listener|protocol|password_file|allow_anonymous)' | sed 's/^/ /'
159
+ done
160
+ (( ${#BROKER_CONF_FILES} == 1 )) && problem "$BROKER_CONF_FILES reads no other files - the Frugal IoT config was never copied into conf.d (step 5)"
161
+ fi
162
+ if (( ${#BROKER_CONF_UNREADABLE} )); then
163
+ item "NOT READABLE as $(id -un), even with sudo -n: ${(j:, :)BROKER_CONF_UNREADABLE}"
164
+ item " everything below about the broker is missing whatever those files say - run with sudo"
165
+ problem "Could not read ${(j:, :)BROKER_CONF_UNREADABLE} - re-run with sudo for a complete answer about the broker"
166
+ fi
167
+ item "files the configuration names, and what a backup does with each (the files above are taken too):"
168
+ # NAMED_FILE, not "path": zsh ties $path to $PATH, so reading into it wipes out every command after
169
+ broker_named_files | while read -r setting NAMED_FILE role; do
170
+ case $role in
171
+ backup) role="taken" ;;
172
+ tls) role="taken (TLS)" ;;
173
+ rebuildable) role="taken - its admin account matches secrets.yaml; the rest is rebuilt from the db" ;;
174
+ package) role="not taken - part of mosquitto" ;;
175
+ live) role="not taken - running state" ;;
176
+ esac
177
+ # A certificate under /etc/letsencrypt/live sits in a root-only directory, so it is invisible to
178
+ # an ordinary user - which is not the same thing as missing.
179
+ if [[ ! -e "$NAMED_FILE" ]] && have sudo && sudo -n test -e "$NAMED_FILE" 2>/dev/null; then
180
+ item " ${(r:22:)setting} ${NAMED_FILE}: present, needs root to see"
181
+ else
182
+ item " ${(r:22:)setting} ${NAMED_FILE}: $(fileinfo $NAMED_FILE)"
183
+ fi
184
+ item " -> ${role}"
145
185
  done
146
- [[ -z "$(print -r -- /etc/mosquitto/conf.d/*(N))" ]] && problem "Nothing in /etc/mosquitto/conf.d - the Frugal IoT config was never copied there (step 5)"
147
-
148
- # The files the broker actually reads. mosquitto's include_dir only loads names ending in .conf,
149
- # so look at exactly those - and the last setting wins, hence "tail -1" everywhere below.
150
- BROKER_CONF_FILES=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
151
186
 
152
- # Everything the broker reads, as one stream - with sudo if reading it plainly gives nothing. A
153
- # file under /etc can be readable only by root, and an ordinary user then gets silence rather than
154
- # an error, which is indistinguishable from "that setting is not configured". Left unguarded, the
155
- # comparison below reported a broker as missing EVERY setting this release ships, which as a
156
- # periodic check is worse than no check at all.
157
- broker_conf_text() {
158
- local t
159
- (( ${#BROKER_CONF_FILES} )) || return 0
160
- t=$(cat "${BROKER_CONF_FILES[@]}" 2>/dev/null) || true
161
- if [[ -z "$t" ]] && have sudo; then
162
- t=$(sudo -n cat "${BROKER_CONF_FILES[@]}" 2>/dev/null) || true
163
- fi
164
- print -r -- "$t"
165
- }
187
+ # Everything the broker reads, as one stream, comments removed
188
+ broker_conf_text() { print -r -- "$BROKER_CONF_TEXT"; }
166
189
 
167
190
  # Where the packaged copy of a shipped file is. What is in ./extras is NOT it: frugal-iot-init
168
191
  # copies extras/ in and then never overwrites it, so once a release changes one of these files the
@@ -170,8 +193,38 @@ broker_conf_text() {
170
193
  PKG_EXTRAS="node_modules/frugal-iot-server/extras"
171
194
  [[ -d "$PKG_EXTRAS" ]] || PKG_EXTRAS="${0:A:h:h}/extras"
172
195
 
196
+ # What this release ships, against what the broker is actually running.
197
+ #
198
+ # This used to be "diff ./extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf", which
199
+ # reported a problem on every machine there has ever been, for three separate reasons: ./extras is
200
+ # a fossil frugal-iot-init will not overwrite, so it differs from the release for ever; the
201
+ # installed copy has the dynamic-security plugin's path substituted, so it differs from the shipped
202
+ # one by design; and "diff -q" against a frugal-iot.conf that does not exist fails too, which is
203
+ # the normal state of a broker whose configuration was assembled by hand as several small files in
204
+ # conf.d. A check that is always wrong is one you stop reading, so this asks the question that
205
+ # actually matters instead: is the broker missing a setting this release ships?
206
+ if [[ -f "${PKG_EXTRAS}/mosquitto.conf" ]] && (( ${#BROKER_CONF_FILES} )); then
207
+ # Setting by setting against what the broker reads, wherever it reads it from - see
208
+ # broker_conf_compare. A file kept at another path is information, not a fault.
209
+ CONF_FINDINGS=$(broker_conf_compare "${PKG_EXTRAS}/mosquitto.conf")
210
+ if [[ -z "$CONF_FINDINGS" ]]; then
211
+ item "the broker has every setting this release ships"
212
+ else
213
+ item "compared with this release's mosquitto.conf:"
214
+ CONF_REAL=0
215
+ for l in ${(f)CONF_FINDINGS}; do
216
+ case ${l%%$'\t'*} in
217
+ missing) item " missing: ${l#*$'\t'}"; (( CONF_REAL++ )) || true ;;
218
+ differs) item " different: ${l#*$'\t'}"; (( CONF_REAL++ )) || true ;;
219
+ elsewhere) item " (fine) ${l#*$'\t'}" ;;
220
+ esac
221
+ done
222
+ (( CONF_REAL )) && problem "The broker's configuration differs from this release's in ${CONF_REAL} setting(s) - see \"Mosquitto configuration\" above. Add them to a file in /etc/mosquitto/conf.d/ (or re-run install-pi.sh on a Pi it set up), then: sudo systemctl restart mosquitto"
223
+ fi
224
+ fi
225
+
173
226
  # Which password file does the running configuration actually name?
174
- CONFIGURED_PWFILE=$(broker_conf_text | grep -hE '^[[:space:]]*password_file[[:space:]]' | tail -1 | awk '{print $2}')
227
+ broker_setting password_file; CONFIGURED_PWFILE="$BROKER_SETTING"
175
228
  section "Mosquitto password file"
176
229
  if [[ -n "$CONFIGURED_PWFILE" ]]; then
177
230
  item "configuration names: $CONFIGURED_PWFILE"
@@ -180,44 +233,15 @@ if [[ -n "$CONFIGURED_PWFILE" ]]; then
180
233
  problem "Mosquitto is configured to use $CONFIGURED_PWFILE but that file does not exist - it will refuse to start"
181
234
  else
182
235
  # Account names only, never the hashes
183
- item " accounts in it: $(cut -d: -f1 "$CONFIGURED_PWFILE" 2>/dev/null | tr '\n' ' ')"
236
+ # Read through the helper: the file is mosquitto:mosquitto 600, so a plain cut as the service
237
+ # user returns nothing, which looks exactly like a file with no accounts in it
238
+ item " accounts in it: $(broker_conf_cat "$CONFIGURED_PWFILE" | cut -d: -f1 | tr '\n' ' ')"
184
239
  [[ ! -s "$CONFIGURED_PWFILE" ]] && item " (the file is empty - no accounts yet)"
185
240
  fi
186
241
  else
187
242
  item "no password_file line found in any mosquitto configuration"
188
243
  problem "No password_file configured - the broker would allow anonymous access or reject everything"
189
244
  fi
190
- # What this release ships, against what the broker is actually running.
191
- #
192
- # This used to be "diff ./extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf", which
193
- # reported a problem on every machine there has ever been, for three separate reasons: ./extras is
194
- # a fossil frugal-iot-init will not overwrite, so it differs from the release for ever; the
195
- # installed copy has the dynamic-security plugin's path substituted, so it differs from the shipped
196
- # one by design; and "diff -q" against a frugal-iot.conf that does not exist fails too, which is
197
- # the normal state of a broker whose configuration was assembled by hand as several small files in
198
- # conf.d. A check that is always wrong is one you stop reading, so this asks the question that
199
- # actually matters instead: is the broker missing a setting this release ships?
200
- if [[ -f "${PKG_EXTRAS}/mosquitto.conf" ]]; then
201
- item "this release's mosquitto.conf names: $(grep -hE '^[[:space:]]*password_file' "${PKG_EXTRAS}/mosquitto.conf" 2>/dev/null | awk '{print $2}')"
202
- if (( ${#BROKER_CONF_FILES} )); then
203
- # The plugin line is normalised: the installer substitutes a path that varies by distribution.
204
- # Comparing settings rather than files means this works whether the configuration arrived as one
205
- # frugal-iot.conf or as several hand-written pieces.
206
- MISSING_CONF=$(comm -23 \
207
- <(sed -E 's#^plugin .*#plugin -#' "${PKG_EXTRAS}/mosquitto.conf" | grep -vE '^[[:space:]]*(#|$)' | sort -u) \
208
- <(broker_conf_text | sed -E 's#^plugin .*#plugin -#' | grep -vE '^[[:space:]]*(#|$)' | sort -u) 2>/dev/null)
209
- # password_file and the listeners are allowed to be sited differently on an older installation,
210
- # so a difference in those is reported without being called a fault.
211
- MISSING_REAL=$(print -r -- "$MISSING_CONF" | grep -vE '^[[:space:]]*(password_file|listener|protocol)[[:space:]]' || true)
212
- if [[ -n "$MISSING_CONF" ]]; then
213
- item "settings this release ships that the broker does not have:"
214
- print -r -- "$MISSING_CONF" | while IFS= read -r l; do [[ -n "$l" ]] && item " $l"; done
215
- else
216
- item "the broker has every setting this release ships"
217
- fi
218
- [[ -n "$MISSING_REAL" ]] && problem "The broker is missing $(print -r -- "$MISSING_REAL" | grep -c .) setting(s) this release ships - see \"Mosquitto password file\" above. Re-run install-pi.sh, or add them by hand if this broker's config.d was written by hand."
219
- fi
220
- fi
221
245
  item "candidate locations, whether or not configured:"
222
246
  for f in /var/lib/mosquitto/passwords /etc/mosquitto/mosquitto_passwords; do
223
247
  item " ${f}: $(fileinfo $f)"
@@ -238,7 +262,7 @@ else
238
262
  item "systemctl not available on this machine"
239
263
  fi
240
264
  # Debian's mosquitto logs to a file rather than the journal, so this is where the real reason is.
241
- MOSQUITTO_LOG=$(grep -hE '^[[:space:]]*log_dest[[:space:]]+file' /etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*(N) 2>/dev/null | tail -1 | awk '{print $3}')
265
+ MOSQUITTO_LOG=$(broker_conf_lines log_dest | awk '$2 == "file" {print $3}' | tail -1)
242
266
  [[ -z "$MOSQUITTO_LOG" ]] && MOSQUITTO_LOG=/var/log/mosquitto/mosquitto.log
243
267
  item "its own log file: ${MOSQUITTO_LOG} ($(fileinfo $MOSQUITTO_LOG))"
244
268
  if [[ -r "$MOSQUITTO_LOG" ]]; then
@@ -347,11 +371,6 @@ section "Broker access control"
347
371
  # An acl_file confines each account to its own organization's topics; without one, every account
348
372
  # that can log in reaches every topic on the broker. Reported here because a broker installed before
349
373
  # the ACL shipped will not have one, and nothing else says so.
350
- BROKER_SETTING=""
351
- broker_setting() { # name -> value from the broker's own configuration, sudo only if needed
352
- # broker_conf_text carries the sudo fallback this used to spell out for itself
353
- BROKER_SETTING=$(broker_conf_text | grep -hE "^[[:space:]]*$1[[:space:]]" | tail -1 | awk '{print $2}') || true
354
- }
355
374
  broker_setting acl_file; ACLFILE="$BROKER_SETTING"
356
375
  if [[ -z "$ACLFILE" ]]; then
357
376
  item "acl_file: NOT SET - every account that can log in can reach every topic, across all organizations"
@@ -495,24 +514,13 @@ section "Bridge to a production server (optional)"
495
514
  # A bridge relays this Pi's readings to a production server - see
496
515
  # extras/mosquitto-bridge.conf.example. It is configured by a "connection" line in one of
497
516
  # mosquitto's own config files, so most installations have none, which is not a problem.
498
- BRIDGECONFS=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
499
517
  # The bridge's own file is 600 root, because it holds the bridge's password - so read as an ordinary
500
518
  # user it yields nothing, which looked exactly like "no bridge configured" on a Pi whose bridge was
501
- # configured and failing. Read each file with sudo when it cannot be read plainly, and say so when
502
- # even that fails rather than report an answer that was never looked for.
503
- BRIDGETEXT=""; BRIDGEUNREAD=()
504
- for f in $BRIDGECONFS; do
505
- if [[ -r $f ]]; then
506
- BRIDGETEXT+="$(<$f)"$'\n'
507
- elif have sudo && t=$(sudo -n cat $f 2>/dev/null); then
508
- BRIDGETEXT+="$t"$'\n'
509
- else
510
- BRIDGEUNREAD+=($f)
511
- fi
512
- done
513
- BRIDGENAMES=$(print -r -- "$BRIDGETEXT" | grep -E '^[[:space:]]*connection[[:space:]]' | awk '{print $2}')
514
- if [[ -z "$BRIDGENAMES" ]] && (( ${#BRIDGEUNREAD} )); then
515
- item "cannot tell whether a bridge is configured: not readable as $(id -un) - ${(j:, :)BRIDGEUNREAD}"
519
+ # configured and failing. broker_conf_read retried it with sudo; say so when even that failed rather
520
+ # than report an answer that was never looked for.
521
+ BRIDGENAMES=$(broker_conf_lines connection | awk '{print $2}')
522
+ if [[ -z "$BRIDGENAMES" ]] && (( ${#BROKER_CONF_UNREADABLE} )); then
523
+ item "cannot tell whether a bridge is configured: not readable as $(id -un) - ${(j:, :)BROKER_CONF_UNREADABLE}"
516
524
  item " sudo npx --no frugal-iot-diagnostic reads them"
517
525
  elif [[ -z "$BRIDGENAMES" ]]; then
518
526
  item "no bridge configured - readings stay on this Pi"
@@ -520,7 +528,7 @@ else
520
528
  for b in ${(f)BRIDGENAMES}; do item "configured bridge: $b"; done
521
529
  # What it is set to relay and where. remote_password is deliberately not among these.
522
530
  item "its settings:"
523
- print -r -- "$BRIDGETEXT" | grep -E '^[[:space:]]*(address|remote_username|remote_clientid|topic|cleansession|restart_timeout|notifications)[[:space:]]' | sed 's/^/ /'
531
+ broker_conf_lines 'address|remote_username|remote_clientid|topic|cleansession|restart_timeout|notifications' | sed 's/^/ /'
524
532
  # Whether it is actually up, rather than what the configuration hoped for. With "notifications"
525
533
  # left on, the bridge publishes a retained 1 or 0 here each time it connects or drops.
526
534
  if have mosquitto_sub; then
@@ -583,6 +591,37 @@ if [[ -d data ]]; then
583
591
  fi
584
592
  fi
585
593
 
594
+ section "Backups"
595
+ # A nightly timer that has quietly stopped looks exactly like one that works, so say how old the
596
+ # newest snapshot is. Only a problem once the timer is installed - before that, nothing promised one.
597
+ BACKUP_DIR=/var/backups/frugal-iot
598
+ BACKUP_TIMER=""
599
+ have systemctl && { BACKUP_TIMER=$(systemctl is-enabled frugal-iot-backup.timer 2>/dev/null) || true; }
600
+ # is-enabled answers "not-found" for a unit that does not exist, rather than nothing
601
+ [[ "$BACKUP_TIMER" == not-found ]] && BACKUP_TIMER=""
602
+ item "timer: ${BACKUP_TIMER:-not installed - npx --no frugal-iot-backup install}"
603
+ # The directory is 700, owned by the account the server runs as - so sudo for anyone else
604
+ NEWEST_BACKUP=$( (ls -1t "$BACKUP_DIR" 2>/dev/null || { have sudo && sudo -n ls -1t "$BACKUP_DIR" 2>/dev/null; }) |
605
+ grep -m1 '^frugal-iot-state-.*\.tar\.gz$') || true
606
+ if [[ -n "$NEWEST_BACKUP" ]]; then
607
+ # The time is in the name, which saves a stat that might need sudo too
608
+ BACKUP_STAMP=${${NEWEST_BACKUP%.tar.gz}[-15,-1]} # YYYYmmdd-HHMMSS
609
+ zmodload zsh/datetime 2>/dev/null
610
+ if strftime -r -s BACKUP_SECS '%Y%m%d-%H%M%S' "$BACKUP_STAMP" 2>/dev/null; then
611
+ BACKUP_HOURS=$(( (EPOCHSECONDS - BACKUP_SECS) / 3600 ))
612
+ item "newest: ${BACKUP_DIR}/${NEWEST_BACKUP} (${BACKUP_HOURS} hours old)"
613
+ [[ "$BACKUP_TIMER" == enabled ]] && (( BACKUP_HOURS > 48 )) &&
614
+ problem "The newest backup is ${BACKUP_HOURS} hours old although the nightly timer is enabled - see: journalctl -u frugal-iot-backup"
615
+ else
616
+ item "newest: ${BACKUP_DIR}/${NEWEST_BACKUP}"
617
+ fi
618
+ elif [[ "$BACKUP_TIMER" == enabled ]]; then
619
+ item "newest: none in ${BACKUP_DIR} (or not readable as $(id -un))"
620
+ problem "The backup timer is enabled but there is no snapshot in ${BACKUP_DIR} - see: journalctl -u frugal-iot-backup"
621
+ else
622
+ item "newest: none"
623
+ fi
624
+
586
625
  section "Wear on the SD card"
587
626
  # An SD card wears out from being written to, and a server logging sensor readings writes constantly
588
627
  # unless told not to. This reports how much is actually being written and whether the settings that
package/scripts/init.zsh CHANGED
@@ -31,18 +31,33 @@ fi
31
31
  COPIED=() # which files this run actually created, so existing ones are never rewritten below
32
32
  DIFFER_TO=() # files kept that no longer match what this release ships, and where to compare them
33
33
  DIFFER_FROM=()
34
+ DIFFER_OURS=() # ...and the lines in each that this release does not have
34
35
  # copy_if_missing <packaged file> <local file> [compare]
35
- # "compare" asks for a warning when the file exists but differs from the one this release ships.
36
- # Only pass it for files nobody is expected to edit - config.yaml, mqtt.yaml and server.yaml are
37
- # this installation's own settings, so of course they differ, and warning about them every time
38
- # would train you to ignore the warning that matters.
36
+ # "compare" is for a file a release extends - the schema - and that someone may also have added to
37
+ # here. When it differs, the question is whether this copy has anything the release does not:
38
+ # - nothing: every line here is still in the release's version, so all the release did was add
39
+ # (a new sensor type, a new field). Installing it loses nothing, so it is installed. Asking
40
+ # instead was a question with only one answer, put to someone who then had to read a diff of a
41
+ # file they never touched to find that out.
42
+ # - something: either it was added here, or the release changed or removed it - and only a person
43
+ # can tell which. So it is kept, and those lines, and only those, are shown.
44
+ # Only pass "compare" for files like that - config.yaml, mqtt.yaml and server.yaml are this
45
+ # installation's own settings, so of course they differ, and warning about them every time would
46
+ # train you to ignore the warning that matters.
39
47
  copy_if_missing() {
40
- local from=$1 to=$2 compare=${3:-}
48
+ local from=$1 to=$2 compare=${3:-} ours
41
49
  if [[ -e "$to" ]]; then
42
50
  if [[ "$compare" == compare && -e "$from" ]] && ! cmp -s "$from" "$to"; then
43
- DIFFER_TO+=("$to")
44
- DIFFER_FROM+=("$from")
45
- echo " kept ${to} (DIFFERS from this release - see below)"
51
+ ours=$(diff "$to" "$from" | sed -n 's/^< //p') || true
52
+ if [[ -z "$ours" ]]; then
53
+ cp "$from" "$to"
54
+ echo " updated ${to} (this release only adds to it)"
55
+ else
56
+ DIFFER_TO+=("$to")
57
+ DIFFER_FROM+=("$from")
58
+ DIFFER_OURS+=("$ours")
59
+ echo " kept ${to} (has lines this release does not - see below)"
60
+ fi
46
61
  else
47
62
  echo " kept ${to} (already present)"
48
63
  fi
@@ -60,7 +75,7 @@ copy_if_missing() {
60
75
 
61
76
  echo "Configuration:"
62
77
  copy_if_missing "${PKG}/config.yaml" "./config.yaml"
63
- for f in logger.yaml mqtt.yaml server.yaml email.yaml; do
78
+ for f in logger.yaml mqtt.yaml server.yaml email.yaml mapping.yaml; do
64
79
  copy_if_missing "${PKG}/config.d/${f}" "./config.d/${f}"
65
80
  done
66
81
  # The schema describes the sensor types the software understands, so a release changing it matters
@@ -192,115 +207,177 @@ else
192
207
  echo " kept ${DB} permissions.project (already present)"
193
208
  fi
194
209
 
210
+ # ---- 3b. Dependency overrides, which npm reads only from THIS directory's package.json ----
211
+ # frugal-iot-server's package.json carries "overrides" that fix vulnerable transitive dependencies
212
+ # (uuid, below firebase-admin in the logger). npm honours overrides only in the top-level project,
213
+ # and here that is this directory's package.json, which just lists frugal-iot-server - so the
214
+ # package's own overrides are ignored, and "npm audit" on an install reports what it reports clean
215
+ # in a checkout. Copied here so that they apply. A dependency needing an override has to have it
216
+ # repeated in the server's package.json, since the logger's own is ignored for the same reason.
217
+ #
218
+ # The release's value replaces a different one here: these exist to raise a floor, and a stale one
219
+ # would bring the vulnerability back. One a release drops is left in place - it is harmless.
220
+ if [[ "$PKG" == */node_modules/frugal-iot-server && -f ./package.json ]]; then
221
+ echo "Dependencies:"
222
+ OVR_SET=$(node -e '
223
+ const fs = require("fs");
224
+ const want = JSON.parse(fs.readFileSync(process.argv[1])).overrides || {};
225
+ const local = JSON.parse(fs.readFileSync("./package.json"));
226
+ const have = local.overrides || {};
227
+ const changed = Object.keys(want).filter(k => JSON.stringify(have[k]) !== JSON.stringify(want[k]));
228
+ if (changed.length) {
229
+ local.overrides = { ...have, ...want };
230
+ fs.writeFileSync("./package.json", JSON.stringify(local, null, 2) + "\n");
231
+ }
232
+ console.log(changed.map(k => k + " " + JSON.stringify(want[k])).join("\n"));
233
+ ' "${PKG}/package.json") || OVR_SET="ERROR"
234
+ if [[ "$OVR_SET" == ERROR ]]; then
235
+ echo " ERROR: could not read or update ./package.json - its overrides are unchanged" >&2
236
+ elif [[ -z "$OVR_SET" ]]; then
237
+ echo " kept ./package.json overrides (already present)"
238
+ else
239
+ for l in ${(f)OVR_SET}; do echo " set ./package.json override ${l}"; done
240
+ # The override changes nothing in node_modules until npm installs again
241
+ if npm install --no-fund --no-audit; then
242
+ echo " updated ./node_modules to match"
243
+ else
244
+ echo " ERROR: npm install failed - run it again in $(pwd) before restarting the server" >&2
245
+ fi
246
+ fi
247
+ fi
248
+
195
249
  # ---- 4. Is what was installed FROM extras/ still current? ----
196
250
  # The files in extras/ are templates for files that live somewhere else - the broker's
197
- # configuration, its ACL, the systemd unit. Whether this directory's copy matches the release says
198
- # nothing about any of them, so the question is asked where the file actually is.
251
+ # configuration, its ACL, the systemd units. Whether this directory's copy matches the release says
252
+ # nothing about any of them, so the question is asked of the file actually in use - found by asking
253
+ # the broker and systemd where that is, never by assuming a Pi's layout. Production's broker
254
+ # configuration is several hand-written files in conf.d, its unit is in /usr/lib/systemd/system, and
255
+ # checking "the usual place" there reported everything as absent, which was true and no help.
199
256
  #
200
- # Each of these is checked in the way that file is allowed to differ, because two of them are
201
- # *supposed* to differ and a plain cmp would cry wolf at every upgrade:
202
- # - mosquitto.conf carries a placeholder for the dynamic security plugin, which the installer
203
- # replaces with a path that varies by distribution.
204
- # - aclfile gets a stanza appended per bridge by addbridge-prod, so the installed file is
205
- # expected to be a superset - only a line this release ships and it lacks is a finding.
206
- STALE=() # installed, and older than this release
207
- UNINSTALLED=() # nothing at the usual place, so there was nothing to compare
208
- UNREADABLE=() # there, but not readable without sudo
257
+ # Each is compared in the way it is allowed to differ, and what is reported is the lines that
258
+ # differ, not an instruction to go and diff it:
259
+ # - the broker's configuration, setting by setting across every file it reads
260
+ # (broker_conf_compare in scripts/broker-config.zsh)
261
+ # - aclfile as a subset: addbridge-prod appends a stanza per bridge, so only a line this release
262
+ # ships and the installed file lacks is a finding
263
+ # - frugaliot.service with User, WorkingDirectory and ExecStart normalised, because the installer
264
+ # rewrites those for whoever runs it and wherever the server went
265
+ STALE=() # "installed|packaged" - and STALE_LINES, what differs, in the same order
266
+ STALE_LINES=()
267
+ UNREADABLE=() # there, but not readable even with sudo -n
268
+
269
+ # norm_text <how>: settings only - comments and blank lines gone - with what an installer
270
+ # legitimately rewrites reduced to its name. Reads its standard input.
271
+ norm_text() {
272
+ local expr='s#^##'
273
+ [[ $1 == service ]] && expr='s#^(User|WorkingDirectory|ExecStart)=.*#\1=(set by the installer)#'
274
+ sed -E "$expr" | grep -vE '^[[:space:]]*(#|$)' || true
275
+ }
209
276
 
210
- # check_installed <packaged file> <installed path> <exact|plugin|subset>
277
+ # check_installed <packaged file> <installed path> <exact|service|subset>
278
+ # The installed file is read with broker_conf_cat, which retries with sudo -n: the aclfile is
279
+ # mosquitto:mosquitto 600, so reading it plainly as the service user always fails.
211
280
  check_installed() {
212
- local from=$1 to=$2 how=$3 a b missing
213
- [[ -e "$from" ]] || return 0
214
- if [[ ! -e "$to" ]]; then
215
- UNINSTALLED+=("${to}|${from}")
216
- return 0
217
- fi
218
- if [[ ! -r "$to" ]]; then
281
+ local from=$1 to=$2 how=$3 here lines
282
+ [[ -e "$from" && -e "$to" ]] || return 0
283
+ if ! here=$(broker_conf_cat "$to"); then
219
284
  UNREADABLE+=("$to")
220
285
  return 0
221
286
  fi
222
- # Both sides through the same filter, so only a real difference survives. Note the "#"
223
- # delimiter: an alternation inside an "s|...|" would end the expression at its first branch, and
224
- # the "|| true" below would then hide the error and report the two files as identical.
225
- local norm
226
287
  case "$how" in
227
- plugin) norm='s#^plugin .*#plugin -#' ;;
228
- # The installer rewrites User, WorkingDirectory and ExecStart for whoever runs it and wherever
229
- # the server went, so those three are normalised - comparing them exactly would report every
230
- # installation that is not user "pi" in /home/pi/frugal-iot as out of date at every upgrade.
231
- service) norm='s#^(User|WorkingDirectory|ExecStart)=.*#\1=-#' ;;
232
- *) norm='' ;;
233
- esac
234
-
235
- case "$how" in
236
- plugin|service)
237
- a=$(sed -E "$norm" "$from" | grep -vE '^[[:space:]]*(#|$)' | sort || true)
238
- b=$(sed -E "$norm" "$to" | grep -vE '^[[:space:]]*(#|$)' | sort || true)
239
- # Neither of these files is ever empty once the comments are gone. Both blank means the
240
- # filter failed rather than that they agree, and reporting "current" on that would be the
241
- # worst answer of the three.
242
- if [[ -z "$a" || -z "$b" ]]; then
243
- UNREADABLE+=("$to")
244
- elif [[ "$a" != "$b" ]]; then
245
- STALE+=("${to}|${from}")
246
- fi
247
- ;;
248
288
  subset)
249
- missing=$(comm -23 <(grep -vE '^[[:space:]]*(#|$)' "$from" | sort -u || true) \
250
- <(grep -vE '^[[:space:]]*(#|$)' "$to" | sort -u || true) || true)
251
- [[ -z "$missing" ]] || STALE+=("${to}|${from}")
289
+ lines=$(comm -23 <(norm_text exact < "$from" | sort -u) <(print -r -- "$here" | norm_text exact | sort -u) |
290
+ sed 's/^/release only: /') || true
252
291
  ;;
253
292
  *)
254
- cmp -s "$from" "$to" || STALE+=("${to}|${from}")
293
+ # "here" and "release", from diff's < and >; its hunk headers say nothing to a reader
294
+ lines=$(diff <(print -r -- "$here" | norm_text "$how") <(norm_text "$how" < "$from") |
295
+ sed -n -e 's/^< /here only: /p' -e 's/^> /release only: /p') || true
255
296
  ;;
256
297
  esac
298
+ if [[ -n "$lines" ]]; then
299
+ STALE+=("${to}|${from}")
300
+ STALE_LINES+=("$lines")
301
+ fi
257
302
  }
258
303
 
259
- check_installed "${PKG}/extras/mosquitto.conf" /etc/mosquitto/conf.d/frugal-iot.conf plugin
260
- check_installed "${PKG}/extras/aclfile" /etc/mosquitto/aclfile subset
261
- check_installed "${PKG}/extras/frugaliot.service" /etc/systemd/system/frugaliot.service service
262
- # Installed as they are, so compared exactly. Absent on a machine with no WiFi, which install-pi.sh
263
- # leaves without a setup network - reported as "nothing to compare", which is what it is.
264
- check_installed "${PKG}/extras/frugaliot-ap-watchdog" /usr/local/sbin/frugaliot-ap-watchdog exact
265
- check_installed "${PKG}/extras/frugaliot-ap-watchdog.service" /etc/systemd/system/frugaliot-ap-watchdog.service exact
266
- check_installed "${PKG}/extras/frugaliot-ap-watchdog.timer" /etc/systemd/system/frugaliot-ap-watchdog.timer exact
304
+ # Where systemd keeps a unit, if it has one - /etc/systemd/system on a Pi, /usr/lib/systemd/system
305
+ # on production
306
+ unit_path() {
307
+ command -v systemctl >/dev/null 2>&1 || return 0
308
+ systemctl show -p FragmentPath --value "$1" 2>/dev/null || true
309
+ }
310
+
311
+ # The broker: what it reads, asked of its own configuration
312
+ source "${PKG}/scripts/broker-config.zsh"
313
+ broker_conf_read
314
+ BROKER_FINDINGS=""
315
+ if (( ${#BROKER_CONF_FILES} )); then
316
+ BROKER_FINDINGS=$(broker_conf_compare "${PKG}/extras/mosquitto.conf")
317
+ broker_setting acl_file
318
+ [[ -n "$BROKER_SETTING" ]] && check_installed "${PKG}/extras/aclfile" "$BROKER_SETTING" subset
319
+ fi
320
+
321
+ SERVICE_UNIT=$(unit_path frugaliot.service)
322
+ [[ -n "$SERVICE_UNIT" ]] && check_installed "${PKG}/extras/frugaliot.service" "$SERVICE_UNIT" service
323
+ # The setup network's watchdog exists only on a Pi with WiFi that install-pi.sh gave one. Anywhere
324
+ # else it is simply not there, and saying so at every upgrade was noise.
325
+ WATCHDOG_UNIT=$(unit_path frugaliot-ap-watchdog.service)
326
+ if [[ -n "$WATCHDOG_UNIT" ]]; then
327
+ check_installed "${PKG}/extras/frugaliot-ap-watchdog.service" "$WATCHDOG_UNIT" exact
328
+ check_installed "${PKG}/extras/frugaliot-ap-watchdog.timer" "$(unit_path frugaliot-ap-watchdog.timer)" exact
329
+ check_installed "${PKG}/extras/frugaliot-ap-watchdog" /usr/local/sbin/frugaliot-ap-watchdog exact
330
+ fi
267
331
 
268
332
  if (( ${#DIFFER_TO} )); then
269
333
  echo
270
- echo "These files were left as you have them, but this release ships a different version:"
334
+ echo "Kept as you have them, because they have lines this release's version does not:"
271
335
  for i in {1..${#DIFFER_TO}}; do
272
336
  echo " ${DIFFER_TO[$i]}"
273
- echo " compare with: diff ${DIFFER_TO[$i]} ${DIFFER_FROM[$i]}"
337
+ print -r -- "${DIFFER_OURS[$i]}" | head -10 | sed 's/^/ /'
338
+ (( $(print -r -- "${DIFFER_OURS[$i]}" | wc -l) > 10 )) && echo " ..."
274
339
  done
275
- echo "The schema describes the sensor types the software understands. If you added one of your"
276
- echo "own that is exactly as it should be; if you did not, this release changed it and the"
277
- echo "difference is worth reading."
340
+ echo "If you added those, nothing needs doing. If you did not, this release changed or removed"
341
+ echo "them, and taking its version is safe:"
342
+ for i in {1..${#DIFFER_TO}}; do echo " cp ${DIFFER_FROM[$i]} ${DIFFER_TO[$i]}"; done
278
343
  fi
279
344
 
280
- if (( ${#STALE} )); then
345
+ if [[ -n "$BROKER_FINDINGS" ]]; then
281
346
  echo
282
- echo "Installed elsewhere from extras/, and older than this release:"
283
- for e in $STALE; do
284
- echo " ${e%%|*}"
285
- echo " compare with: sudo diff ${e%%|*} ${e#*|}"
347
+ echo "The broker's configuration, compared setting by setting with this release's"
348
+ echo "extras/mosquitto.conf, across the ${#BROKER_CONF_FILES} file(s) it reads:"
349
+ BROKER_REAL=0
350
+ for l in ${(f)BROKER_FINDINGS}; do
351
+ case ${l%%$'\t'*} in
352
+ missing) echo " missing: ${l#*$'\t'}"; (( BROKER_REAL++ )) || true ;;
353
+ differs) echo " different: ${l#*$'\t'}"; (( BROKER_REAL++ )) || true ;;
354
+ elsewhere) echo " (fine) ${l#*$'\t'}" ;;
355
+ esac
286
356
  done
287
- echo "Re-running the installer brings these up to date and is the safe way to do it - it"
288
- echo "substitutes the plugin path and will not discard ACL rules added per bridge:"
289
- echo " bash node_modules/frugal-iot-server/scripts/install-pi.sh"
357
+ if (( BROKER_REAL )); then
358
+ echo "To bring it in line, put the settings above in any file in /etc/mosquitto/conf.d/ - on a Pi"
359
+ echo "set up by install-pi.sh, re-running it does that - then: sudo systemctl restart mosquitto"
360
+ fi
290
361
  fi
362
+ (( ${#BROKER_CONF_UNREADABLE} )) &&
363
+ { echo; echo "Broker configuration not readable as $(id -un), so the comparison above is incomplete: ${(j:, :)BROKER_CONF_UNREADABLE}"; }
291
364
 
292
- if (( ${#UNINSTALLED} )); then
365
+ if (( ${#STALE} )); then
293
366
  echo
294
- echo "Nothing at the usual place, so these were not compared:"
295
- for e in $UNINSTALLED; do echo " ${e%%|*} (would come from extras/${${e#*|}:t})"; done
296
- echo "Either install-pi.sh has not been run here, or this machine's broker configuration was put"
297
- echo "together by hand under another name - several small files in /etc/mosquitto/conf.d is a"
298
- echo "normal way to have done it. Nothing is out of date; there is simply nothing to compare."
367
+ echo "Installed elsewhere from extras/, and different from this release:"
368
+ for i in {1..${#STALE}}; do
369
+ echo " ${STALE[$i]%%|*}"
370
+ print -r -- "${STALE_LINES[$i]}" | sed 's/^/ /'
371
+ done
372
+ echo "Bring them in line by hand - change the \"here only\" lines to the \"release only\" ones - or on"
373
+ echo "a Pi set up by it, by re-running install-pi.sh. The whole difference, if you want it:"
374
+ for e in $STALE; do echo " sudo diff ${e%%|*} ${e#*|}"; done
299
375
  fi
300
376
 
301
377
  if (( ${#UNREADABLE} )); then
302
378
  echo
303
- echo "There, but not readable as $(id -un), so not compared: ${(j:, :)UNREADABLE}"
379
+ echo "There, but not readable as $(id -un) without a sudo password, so not compared: ${(j:, :)UNREADABLE}"
380
+ echo "To compare them too: sudo -v, then run this again"
304
381
  fi
305
382
 
306
383
  echo
@@ -635,6 +635,9 @@ CODE=""; i=0
635
635
  while [ "$i" -lt 20 ]; do
636
636
  CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://localhost:${PORT}/" || true)
637
637
  [ "$CODE" = 200 ] && break
638
+ # "/" is the static front page; the login page is the thing that proves the dashboard is served
639
+ CODE2=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://localhost:${PORT}/dashboard/login.html" || true)
640
+ [ "$CODE2" = 200 ] && { CODE=200; break; }
638
641
  sleep 2; i=$((i+1))
639
642
  done
640
643
  if [ "$CODE" = 200 ]; then
@@ -18,6 +18,7 @@ extras/mosquitto_passwords
18
18
  .gitignore
19
19
  claude.md
20
20
  create-favicon.js
21
+ BACKUP_PLAN.md
21
22
  jest.config.js
22
23
  vitest.config.js
23
24
  test/**