frugal-iot-server 2.0.1 → 2.0.3

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/package.json CHANGED
@@ -12,8 +12,8 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^2.0.1",
16
- "frugal-iot-logger": "^2.0.1",
15
+ "frugal-iot-client": "^2.0.3",
16
+ "frugal-iot-logger": "^2.0.3",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -73,7 +73,10 @@
73
73
  "license": "MIT",
74
74
  "main": "frugal-iot-server.js",
75
75
  "name": "frugal-iot-server",
76
- "repository": "https://github.com/mitra42/frugal-iot-server",
76
+ "repository": {
77
+ "type": "git",
78
+ "url": "git+https://github.com/mitra42/frugal-iot-server.git"
79
+ },
77
80
  "scripts": {
78
81
  "test": "vitest run",
79
82
  "test:watch": "vitest",
@@ -83,5 +86,5 @@
83
86
  "check-files": "node scripts/check-published-files.js"
84
87
  },
85
88
  "type": "module",
86
- "version": "2.0.1"
89
+ "version": "2.0.3"
87
90
  }
@@ -6,7 +6,7 @@
6
6
  // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
7
  // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
8
8
  // needs this bumped, or it does not reach anyone who already has the old one.
9
- const CACHE_NAME = 'frugal-iot-cache-2.0.1';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.3';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
@@ -144,15 +144,28 @@ fi
144
144
  # user *before* reading it - so the broker then cannot read its own password file and refuses to
145
145
  # start, with an error that says nothing about ownership. mosquitto_passwd also writes a temporary
146
146
  # backup beside the file, so it needs the directory and not only the file.
147
+ #
148
+ # Whoever runs it must be able to write the DIRECTORY, not merely the file, and the file's owner
149
+ # frequently cannot: a password file at /etc/mosquitto/mosquitto_passwords is owned by mosquitto
150
+ # inside a directory owned by root, and picking the owner then fails with "Error creating backup
151
+ # password file". So the last resort is root - and because that leaves the file owned by root,
152
+ # where mosquitto (which drops privileges before reading it) could no longer read it, the
153
+ # ownership and mode are put back immediately afterwards.
154
+ # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
155
+ PWDIR="${PWFILE:h}"
156
+ PWFILE_OWNER=$(stat -c '%U' "$PWFILE" 2>/dev/null || stat -f '%Su' "$PWFILE" 2>/dev/null || true)
157
+ PWFILE_GROUP=$(stat -c '%G' "$PWFILE" 2>/dev/null || stat -f '%Sg' "$PWFILE" 2>/dev/null || true)
158
+ PWFILE_MODE=$(stat -c '%a' "$PWFILE" 2>/dev/null || stat -f '%Lp' "$PWFILE" 2>/dev/null || true)
159
+ RESTORE_OWNER=0
147
160
  MOSQ_PASSWD_CMD=(mosquitto_passwd)
148
- if [[ ! -w "${PWFILE:h}" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
161
+ if [[ ! -w "$PWDIR" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
149
162
  if command -v sudo >/dev/null; then
150
- # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
151
- PWFILE_OWNER=$(stat -c '%U' "$PWFILE" 2>/dev/null || stat -f '%Su' "$PWFILE" 2>/dev/null || true)
152
- if [[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "$(id -un)" && "$PWFILE_OWNER" != "root" ]]; then
163
+ if [[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "$(id -un)" && "$PWFILE_OWNER" != "root" ]] \
164
+ && sudo -u "$PWFILE_OWNER" test -w "$PWDIR" 2>/dev/null; then
153
165
  MOSQ_PASSWD_CMD=(sudo -u "$PWFILE_OWNER" mosquitto_passwd)
154
166
  else
155
167
  MOSQ_PASSWD_CMD=(sudo mosquitto_passwd)
168
+ [[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "root" ]] && RESTORE_OWNER=1
156
169
  fi
157
170
  fi
158
171
  fi
@@ -162,6 +175,12 @@ if ! $MOSQ_PASSWD_CMD -b "$PWFILE" "$ACCOUNT" "$PASSWORD"; then
162
175
  echo "Error: mosquitto_passwd failed - nothing has been changed." >&2
163
176
  exit 1
164
177
  fi
178
+ if (( RESTORE_OWNER )); then
179
+ # Written as root, so hand it back before mosquitto next needs to read it
180
+ sudo chown "${PWFILE_OWNER}:${PWFILE_GROUP}" "$PWFILE"
181
+ [[ -n "$PWFILE_MODE" ]] && sudo chmod "$PWFILE_MODE" "$PWFILE"
182
+ echo " (written as root, then given back to ${PWFILE_OWNER}:${PWFILE_GROUP} mode ${PWFILE_MODE})"
183
+ fi
165
184
 
166
185
  # The access control rule. Without one the account can connect and reach nothing at all, because a
167
186
  # mosquitto acl_file is deny-by-default.
@@ -171,15 +190,42 @@ if [[ -n "$ACLFILE" ]]; then
171
190
  echo "The account has been created but cannot reach anything until that is sorted out." >&2
172
191
  exit 1
173
192
  fi
174
- if sudo grep -qE "^[[:space:]]*user[[:space:]]+${ACCOUNT}[[:space:]]*$" "$ACLFILE"; then
175
- echo "ACL rule for ${ACCOUNT} is already in ${ACLFILE} - leaving it alone."
193
+ #
194
+ # Two grants, and the second is easy to leave out.
195
+ #
196
+ # A bridge with "notifications true" publishes a retained 1/0 to
197
+ # $SYS/broker/connection/<clientid>/state on the local AND the remote broker, and that is what the
198
+ # dashboard's Bridges card reads. The bridge's LOCAL side is exempt from the ACL, so the Pi always
199
+ # has it; the remote side is not, and on a deny-by-default broker the write is refused silently -
200
+ # so the card said "Unknown" for a bridge that was up and relaying perfectly. Measured on
201
+ # mosquitto 2.0.20: the write is allowed once the ACL says so, so this is an ACL matter and not
202
+ # one of mosquitto refusing $SYS writes outright.
203
+ ACL_ORG_RULE=0; ACL_SYS_RULE=0
204
+ sudo grep -qE "^[[:space:]]*user[[:space:]]+${ACCOUNT}[[:space:]]*$" "$ACLFILE" && ACL_ORG_RULE=1
205
+ sudo grep -qF "topic write \$SYS/broker/connection/${ACCOUNT}/state" "$ACLFILE" && ACL_SYS_RULE=1
206
+
207
+ if (( ACL_ORG_RULE && ACL_SYS_RULE )); then
208
+ echo "ACL rules for ${ACCOUNT} are already in ${ACLFILE} - leaving them alone."
209
+ elif (( ACL_ORG_RULE )); then
210
+ # An existing bridge from before the state grant existed. A second "user" stanza for the same
211
+ # account is valid and additive, which is simpler and safer than editing the first one in place.
212
+ echo "Adding the connection-state ACL rule for ${ACCOUNT} to ${ACLFILE} ..."
213
+ sudo tee -a "$ACLFILE" >/dev/null <<EOF
214
+
215
+ # Lets the bridge report whether it is connected, for the dashboard's Bridges card
216
+ user ${ACCOUNT}
217
+ topic write \$SYS/broker/connection/${ACCOUNT}/state
218
+ EOF
219
+ echo " Restart the Pi's broker to have it republish that state: it is sent on connect only."
176
220
  else
177
- echo "Adding ACL rule to ${ACLFILE} ..."
221
+ echo "Adding ACL rules to ${ACLFILE} ..."
178
222
  sudo tee -a "$ACLFILE" >/dev/null <<EOF
179
223
 
180
224
  # Pi bridge for site "${SITE}", confined to its organization's topics
181
225
  user ${ACCOUNT}
182
226
  topic readwrite ${ORG_ID}/#
227
+ # ... and its own connection state, which the dashboard's Bridges card reads
228
+ topic write \$SYS/broker/connection/${ACCOUNT}/state
183
229
  EOF
184
230
  fi
185
231
  else
@@ -237,16 +283,21 @@ if [[ -z "$REPLICA_TOKEN" ]]; then
237
283
  fi
238
284
 
239
285
  echo ""
240
- echo "Done. On the Pi at site '${SITE}', run:"
286
+ echo "Done. Collect these, then run the command at the bottom on the Pi."
241
287
  echo ""
242
- echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <this server's public name> ${ACCOUNT}"
288
+ echo "1. THE FQDN OF THIS SERVER - the DNS name on its TLS certificate."
289
+ echo " The Pi verifies the certificate against exactly what you type, so a local hostname, an IP"
290
+ echo " address, or a domain the certificate does not cover will fail to connect."
291
+ echo " TO TEST IT: open https://<name>/ in a browser. Loading with no certificate warning means"
292
+ echo " the name matches the certificate, which is the part that is easy to get wrong. The bridge"
293
+ echo " then uses port 8883 on that same host, so that port has to be open to the Pi as well."
294
+ echo " This host calls itself \"$(hostname -f 2>/dev/null || hostname)\" - use that only if it is"
295
+ echo " also the name on the certificate and one the Pi can resolve. Not guessed here, because"
296
+ echo " what a machine calls itself usually is not its public name."
243
297
  echo ""
244
- echo "Positional arguments on purpose: npm swallows any --flag it does not recognise and passes"
245
- echo "only the value on, so the --org form would arrive at that script as a bare word."
298
+ echo "2. THE PASSWORD for ${ACCOUNT}, which the Pi's script will prompt for:"
246
299
  echo ""
247
- echo "It will ask for this password:"
248
- echo ""
249
- echo " ${PASSWORD}"
300
+ echo " ${PASSWORD}"
250
301
  echo ""
251
302
  if [[ "$GENERATED" == true ]]; then
252
303
  echo "That was generated just now and is not stored anywhere you can read it back - copy it before"
@@ -255,15 +306,23 @@ if [[ "$GENERATED" == true ]]; then
255
306
  echo "and use that on the Pi instead."
256
307
  fi
257
308
  if [[ -n "$REPLICA_TOKEN" ]]; then
258
- echo "And this replica token, so people registered here can log in on the Pi as well:"
309
+ echo "3. THE REPLICA TOKEN, so people registered here can log in on the Pi as well:"
259
310
  echo ""
260
- echo " ${REPLICA_TOKEN}"
311
+ echo " ${REPLICA_TOKEN}"
261
312
  echo ""
262
- echo "A new one replaces the old, so re-running this script revokes the Pi's previous token."
313
+ echo " A new one replaces the old, so re-running this script revokes the Pi's previous token."
263
314
  echo ""
264
315
  fi
265
- echo "Fill in the public name yourself - it is deliberately not guessed here, because what this"
266
- echo "machine calls itself is usually not it. This host answers to \"$(hostname -f 2>/dev/null || hostname)\","
267
- echo "which is only the right answer if that is also the DNS name the Pi can reach and the name on"
268
- echo "the TLS certificate. The Pi verifies the certificate against whatever you put here, so a"
269
- echo "local hostname, or a domain the certificate does not cover, fails to connect."
316
+ echo "Then, on the Pi at site '${SITE}':"
317
+ echo ""
318
+ echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT}"
319
+ echo ""
320
+ if [[ -n "$REPLICA_TOKEN" ]]; then
321
+ echo "It asks for the password from 2, then for the replica token from 3. Neither is given on the"
322
+ echo "command line on purpose, so that neither ends up in that Pi's shell history."
323
+ else
324
+ echo "It asks for the password from 2. Neither secret is given on the command line on purpose, so"
325
+ echo "that neither ends up in that Pi's shell history."
326
+ fi
327
+ echo "The arguments are positional because npm swallows any --flag it does not recognise and passes"
328
+ echo "only the value on, so the --org form would arrive at that script as a bare word."
@@ -107,19 +107,38 @@ fi
107
107
  # opening it, and mosquitto_passwd writes a temporary backup beside it, so it needs the directory too
108
108
  # - neither of which we get by running as ourselves against a file owned by mosquitto or by root.
109
109
  # On Raspberry Pi OS the first user has passwordless sudo, so this is invisible.
110
+ #
111
+ # The owner is not always able to do it either: a file at /etc/mosquitto/mosquitto_passwords is
112
+ # owned by mosquitto inside a directory owned by root, and the backup then cannot be written -
113
+ # "Error creating backup password file". So check the chosen account can write the directory, and
114
+ # fall back to root, putting the ownership back afterwards so mosquitto can still read the file.
115
+ # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
116
+ MOSQUITTO_PASSWD_DIR="${MOSQUITTO_PASSWD_FILE:h}"
117
+ MOSQUITTO_PASSWD_OWNER=$(stat -c '%U' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
118
+ || stat -f '%Su' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
119
+ MOSQUITTO_PASSWD_GROUP=$(stat -c '%G' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
120
+ || stat -f '%Sg' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
121
+ MOSQUITTO_PASSWD_MODE=$(stat -c '%a' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
122
+ || stat -f '%Lp' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
123
+ MOSQUITTO_RESTORE_OWNER=0
110
124
  MOSQUITTO_PASSWD_CMD=(mosquitto_passwd)
111
- if [[ ! -w "${MOSQUITTO_PASSWD_FILE:h}" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MOSQUITTO_PASSWD_FILE" ) ]]; then
125
+ if [[ ! -w "$MOSQUITTO_PASSWD_DIR" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MOSQUITTO_PASSWD_FILE" ) ]]; then
112
126
  if command -v sudo >/dev/null; then
113
- # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
114
- MOSQUITTO_PASSWD_OWNER=$(stat -c '%U' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
115
- || stat -f '%Su' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
116
- if [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "$(id -un)" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]]; then
127
+ if [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "$(id -un)" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]] \
128
+ && sudo -u "$MOSQUITTO_PASSWD_OWNER" test -w "$MOSQUITTO_PASSWD_DIR" 2>/dev/null; then
117
129
  MOSQUITTO_PASSWD_CMD=(sudo -u "$MOSQUITTO_PASSWD_OWNER" mosquitto_passwd)
118
130
  else
119
131
  MOSQUITTO_PASSWD_CMD=(sudo mosquitto_passwd)
132
+ [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]] && MOSQUITTO_RESTORE_OWNER=1
120
133
  fi
121
134
  fi
122
135
  fi
136
+ # Called after any successful mosquitto_passwd run below
137
+ restore_passwd_owner() {
138
+ (( MOSQUITTO_RESTORE_OWNER )) || return 0
139
+ sudo chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
140
+ [[ -n "$MOSQUITTO_PASSWD_MODE" ]] && sudo chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
141
+ }
123
142
 
124
143
  # ---- Escaping helpers ----
125
144
  # Escape a value for embedding in a double-quoted YAML string
@@ -229,6 +248,7 @@ else
229
248
  echo "If it complains about creating a backup file, the directory holding that file is not" >&2
230
249
  echo "writable by you - which is why the command above uses sudo." >&2
231
250
  else
251
+ restore_passwd_owner
232
252
  echo "Set mosquitto password for ${ORG_ID} in ${MOSQUITTO_PASSWD_FILE}"
233
253
  fi
234
254
  fi
@@ -10,10 +10,40 @@
10
10
  #
11
11
  # Paste the whole output into a bug report. Passwords are deliberately not printed: the password
12
12
  # file is reported by account name only, and organization configs by name only.
13
-
13
+ #
14
+ # QUIET MODE says nothing at all unless something is wrong, which is what makes it usable as a
15
+ # periodic check rather than only as something to read when you already have a problem:
16
+ # npx --no frugal-iot-diagnostic quiet # positional, see below
17
+ # zsh diagnostic.zsh -q
18
+ #
19
+ # The word "quiet" with no dash is not a typo. npm parses the whole command line itself and passes
20
+ # only the values through, so a flag never reaches a script run by npx - and "-q" is worse than
21
+ # most, because it is npm's OWN abbreviation for --quiet, so npm consumes it and goes quiet itself.
22
+ # "npx --no frugal-iot-diagnostic -- --quiet" also works, if you would rather remember that.
23
+ #
24
+ # Exit status, either mode:
25
+ # 0 nothing found
26
+ # 1 problems found - they are listed, and are the only thing a quiet run prints
27
+ #
14
28
  # No "set -e": a failing check should report and carry on, not stop the script.
15
29
  setopt no_unset 2>/dev/null
16
30
 
31
+ QUIET=0
32
+ for arg in "$@"; do
33
+ case "$arg" in
34
+ -q|--quiet|quiet) QUIET=1 ;;
35
+ -h|--help|help) sed -n '3,28p' "$0" | sed -e 's/^#$//' -e 's/^# //'; exit 0 ;;
36
+ *) print -u2 "Unknown argument: $arg (try \"quiet\", or \"help\")"; exit 2 ;;
37
+ esac
38
+ done
39
+
40
+ # Keep the real stdout on fd 3 for the summary, which prints in both modes. Under -q everything
41
+ # else - including whatever the commands being run write - goes nowhere, so a quiet run that
42
+ # prints anything is a quiet run that found something. stderr goes too: the noise it carries is
43
+ # things like sudo declining, which the checks below already turn into findings of their own.
44
+ exec 3>&1
45
+ (( QUIET )) && exec 1>/dev/null 2>/dev/null
46
+
17
47
  section() { print -r -- ""; print -r -- "===== $* ====="; }
18
48
  item() { print -r -- " $*"; }
19
49
  have() { command -v "$1" >/dev/null 2>&1; }
@@ -115,9 +145,33 @@ for f in /etc/mosquitto/conf.d/*(N); do
115
145
  done
116
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)"
117
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
+
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
+ }
166
+
167
+ # Where the packaged copy of a shipped file is. What is in ./extras is NOT it: frugal-iot-init
168
+ # copies extras/ in and then never overwrites it, so once a release changes one of these files the
169
+ # local copy is a fossil. Checks below read the package.
170
+ PKG_EXTRAS="node_modules/frugal-iot-server/extras"
171
+ [[ -d "$PKG_EXTRAS" ]] || PKG_EXTRAS="${0:A:h:h}/extras"
172
+
118
173
  # Which password file does the running configuration actually name?
119
- # mosquitto's include_dir only loads files ending in .conf, so look at exactly those, last one wins
120
- CONFIGURED_PWFILE=$(grep -hE '^[[:space:]]*password_file[[:space:]]' /etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N) 2>/dev/null | tail -1 | awk '{print $2}')
174
+ CONFIGURED_PWFILE=$(broker_conf_text | grep -hE '^[[:space:]]*password_file[[:space:]]' | tail -1 | awk '{print $2}')
121
175
  section "Mosquitto password file"
122
176
  if [[ -n "$CONFIGURED_PWFILE" ]]; then
123
177
  item "configuration names: $CONFIGURED_PWFILE"
@@ -133,12 +187,35 @@ else
133
187
  item "no password_file line found in any mosquitto configuration"
134
188
  problem "No password_file configured - the broker would allow anonymous access or reject everything"
135
189
  fi
136
- # The copy in this installation is what step 5 tells you to put into /etc - if the two disagree,
137
- # either the copy never happened or the installed package is older than the instructions.
138
- if [[ -f extras/mosquitto.conf ]]; then
139
- item "this installation's extras/mosquitto.conf names: $(grep -hE '^[[:space:]]*password_file' extras/mosquitto.conf 2>/dev/null | awk '{print $2}')"
140
- if [[ -n "$CONFIGURED_PWFILE" ]] && ! diff -q extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf >/dev/null 2>&1; then
141
- problem "extras/mosquitto.conf here differs from /etc/mosquitto/conf.d/frugal-iot.conf - if you followed step 5 with an older installed package, copy it again after upgrading"
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."
142
219
  fi
143
220
  fi
144
221
  item "candidate locations, whether or not configured:"
@@ -270,15 +347,10 @@ section "Broker access control"
270
347
  # An acl_file confines each account to its own organization's topics; without one, every account
271
348
  # that can log in reaches every topic on the broker. Reported here because a broker installed before
272
349
  # the ACL shipped will not have one, and nothing else says so.
273
- BROKER_CONF_FILES=(/etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N))
274
350
  BROKER_SETTING=""
275
351
  broker_setting() { # name -> value from the broker's own configuration, sudo only if needed
276
- local v
277
- v=$(grep -hE "^[[:space:]]*$1[[:space:]]" "${BROKER_CONF_FILES[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
278
- if [[ -z "$v" ]] && have sudo; then
279
- v=$(sudo -n grep -hE "^[[:space:]]*$1[[:space:]]" "${BROKER_CONF_FILES[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
280
- fi
281
- BROKER_SETTING="$v"
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
282
354
  }
283
355
  broker_setting acl_file; ACLFILE="$BROKER_SETTING"
284
356
  if [[ -z "$ACLFILE" ]]; then
@@ -317,11 +389,45 @@ else
317
389
  fi
318
390
  fi
319
391
  # Can the server actually drive it? That needs the admin credential frugal-iot-init recorded.
320
- DYNUSER=$(sed -n 's/^dynsec_admin_user:[[:space:]]*//p' config.d/secrets.yaml 2>/dev/null | tr -d '"'"'"'' | head -1)
321
- DYNPW=$(sed -n 's/^dynsec_admin_password:[[:space:]]*//p' config.d/secrets.yaml 2>/dev/null | tr -d '"'"'"'' | head -1)
392
+ #
393
+ # Read with a sudo fallback, and report the file's ownership, because the two ways this goes wrong
394
+ # look identical from here: the setting really is absent, or the file belongs to somebody else and
395
+ # an ordinary reader gets silence. The second is the one that matters, since the SERVER is an
396
+ # ordinary reader too - it runs as the account in the unit file, and a secrets.yaml it cannot read
397
+ # leaves it with no credential to hand a browser, whatever is written inside.
398
+ item " secrets file: config.d/secrets.yaml $(fileinfo config.d/secrets.yaml)"
399
+ SERVICE_USER=$(sed -n 's/^User=//p' /etc/systemd/system/frugaliot.service 2>/dev/null | head -1)
400
+ [[ -z "$SERVICE_USER" ]] && SERVICE_USER=$(sudo -n sed -n 's/^User=//p' /etc/systemd/system/frugaliot.service 2>/dev/null | head -1)
401
+ secret_value() { # name -> its value, with sudo if reading it plainly gives nothing
402
+ local v
403
+ v=$(sed -n "s/^$1:[[:space:]]*//p" config.d/secrets.yaml 2>/dev/null | tr -d "\"'" | head -1)
404
+ if [[ -z "$v" ]] && have sudo; then
405
+ v=$(sudo -n sed -n "s/^$1:[[:space:]]*//p" config.d/secrets.yaml 2>/dev/null | tr -d "\"'" | head -1)
406
+ fi
407
+ print -r -- "$v"
408
+ }
409
+ if [[ -f config.d/secrets.yaml && -n "$SERVICE_USER" ]] && have stat; then
410
+ SECRETS_OWNER=$(stat -c '%U' config.d/secrets.yaml 2>/dev/null || true)
411
+ if [[ -n "$SECRETS_OWNER" && "$SECRETS_OWNER" != "$SERVICE_USER" && "$SECRETS_OWNER" != root ]]; then
412
+ problem "config.d/secrets.yaml belongs to ${SECRETS_OWNER} but the server runs as ${SERVICE_USER}, so the server cannot read it. Fix with: sudo chown ${SERVICE_USER} config.d/secrets.yaml"
413
+ elif [[ "$SECRETS_OWNER" == root && "$SERVICE_USER" != root ]]; then
414
+ problem "config.d/secrets.yaml belongs to root but the server runs as ${SERVICE_USER}, so the server reads nothing from it - every login is told live data is unavailable. Fix with: sudo chown ${SERVICE_USER} config.d/secrets.yaml && sudo systemctl restart frugaliot"
415
+ fi
416
+ fi
417
+ # Without this a login gets no broker credential at all, whatever else is right - which shows up
418
+ # in the dashboard as "No broker credential for this login".
419
+ USERSECRET=$(secret_value user_secret)
420
+ if [[ -z "$USERSECRET" ]]; then
421
+ item " user_secret: MISSING - no login can be given a broker credential"
422
+ problem "config.d/secrets.yaml has no user_secret, so every login is told \"No broker credential for this login\". The server writes one at startup; if it cannot, it says so in its log."
423
+ else
424
+ item " user_secret: present (each login's broker password is derived from it)"
425
+ fi
426
+ DYNUSER=$(secret_value dynsec_admin_user)
427
+ DYNPW=$(secret_value dynsec_admin_password)
322
428
  if [[ -z "$DYNUSER" || -z "$DYNPW" ]]; then
323
- item " admin credential: not in config.d/secrets.yaml - the server cannot create accounts"
324
- problem "dynamic security is enabled but config.d/secrets.yaml has no dynsec_admin_user/password"
429
+ item " admin credential: not readable in config.d/secrets.yaml - the server cannot create accounts"
430
+ problem "dynamic security is enabled but config.d/secrets.yaml has no readable dynsec_admin_user/password, so no login gets a broker credential"
325
431
  elif have mosquitto_ctrl; then
326
432
  if DYNOUT=$(mosquitto_ctrl -h localhost -u "$DYNUSER" -P "$DYNPW" dynsec listClients 2>&1 | grep -viE 'without encryption|visible on the network'); then
327
433
  item " admin credential works ($(print -r -- $DYNOUT | tr '\n' ' ' | cut -c1-60))"
@@ -515,8 +621,11 @@ if [[ -f config.d/server.yaml ]]; then
515
621
  MORGANSET=$(sed -n 's/^morgan:[[:space:]]*//p' config.d/server.yaml 2>/dev/null | head -1)
516
622
  item "server morgan: ${MORGANSET:-not set, so on - a line logged per HTTP request}"
517
623
  fi
518
- if [[ -f /etc/mosquitto/conf.d/frugal-iot.conf ]]; then
519
- if grep -qE '^[[:space:]]*connection_messages[[:space:]]+false' /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
624
+ # Across every file the broker reads, not just frugal-iot.conf: a configuration assembled by hand
625
+ # puts this wherever it likes, and looking in one file meant a broker that logs every connect and
626
+ # disconnect was never reported as doing so.
627
+ if (( ${#BROKER_CONF_FILES} )); then
628
+ if broker_conf_text | grep -qhE '^[[:space:]]*connection_messages[[:space:]]+false'; then
520
629
  item "mosquitto connections: not logged"
521
630
  else
522
631
  item "mosquitto connections: logged - every connect and disconnect is a write"
@@ -539,10 +648,23 @@ if [[ -d data ]]; then
539
648
  [[ -n "$OLDEST" ]] && item " oldest day: ${OLDEST}"
540
649
  fi
541
650
 
542
- section "Summary"
651
+ # The summary prints to fd 3, which is the real stdout whether or not the rest was silenced. A
652
+ # quiet run with nothing to report prints nothing at all, which is the point of it.
543
653
  if (( ${#PROBLEMS} == 0 )); then
544
- item "No problems detected by these checks."
545
- else
546
- for p in $PROBLEMS; do item "PROBLEM: $p"; done
654
+ if (( ! QUIET )); then
655
+ print -r -- "" >&3
656
+ print -r -- "===== Summary =====" >&3
657
+ print -r -- " No problems detected by these checks." >&3
658
+ print -r -- "" >&3
659
+ fi
660
+ exit 0
661
+ fi
662
+ print -r -- "" >&3
663
+ print -r -- "===== Summary: ${#PROBLEMS} problem(s) =====" >&3
664
+ for p in $PROBLEMS; do print -r -- " PROBLEM: $p" >&3; done
665
+ if (( QUIET )); then
666
+ print -r -- "" >&3
667
+ print -r -- " Run it again without \"quiet\" for the whole report." >&3
547
668
  fi
548
- print -r -- ""
669
+ print -r -- "" >&3
670
+ exit 1
package/scripts/init.zsh CHANGED
@@ -63,10 +63,16 @@ done
63
63
  for f in "${PKG}"/config.d/schema/*.yaml(N); do
64
64
  copy_if_missing "$f" "./config.d/schema/${f:t}" compare
65
65
  done
66
- # Copied so that mosquitto.conf and frugaliot.service can be edited and installed from here. Worth
67
- # comparing: these get copied on somewhere else (/etc/...), where an old version lingers unnoticed.
66
+ # Copied so that mosquitto.conf and frugaliot.service can be there to read and install from.
67
+ #
68
+ # NOT compared against the packaged copy. This copy is never overwritten - that is the whole point
69
+ # of copy_if_missing - so once a release changes one of these it differs for ever, and says so at
70
+ # every upgrade whether or not anything is actually out of date. A warning that is always wrong is
71
+ # one you learn to skip past, and then miss on the day it is right. What matters is not whether
72
+ # this copy is current but whether the file actually in use is, so that is what is checked, below,
73
+ # where it really lives.
68
74
  for f in "${PKG}"/extras/*(N); do
69
- copy_if_missing "$f" "./extras/${f:t}" compare
75
+ copy_if_missing "$f" "./extras/${f:t}"
70
76
  done
71
77
 
72
78
  # ---- 1b. Secrets this server generates for itself ----
@@ -182,6 +188,74 @@ else
182
188
  echo " kept ${DB} permissions.project (already present)"
183
189
  fi
184
190
 
191
+ # ---- 4. Is what was installed FROM extras/ still current? ----
192
+ # The files in extras/ are templates for files that live somewhere else - the broker's
193
+ # configuration, its ACL, the systemd unit. Whether this directory's copy matches the release says
194
+ # nothing about any of them, so the question is asked where the file actually is.
195
+ #
196
+ # Each of these is checked in the way that file is allowed to differ, because two of them are
197
+ # *supposed* to differ and a plain cmp would cry wolf at every upgrade:
198
+ # - mosquitto.conf carries a placeholder for the dynamic security plugin, which the installer
199
+ # replaces with a path that varies by distribution.
200
+ # - aclfile gets a stanza appended per bridge by addbridge-prod, so the installed file is
201
+ # expected to be a superset - only a line this release ships and it lacks is a finding.
202
+ STALE=() # installed, and older than this release
203
+ UNINSTALLED=() # nothing at the usual place, so there was nothing to compare
204
+ UNREADABLE=() # there, but not readable without sudo
205
+
206
+ # check_installed <packaged file> <installed path> <exact|plugin|subset>
207
+ check_installed() {
208
+ local from=$1 to=$2 how=$3 a b missing
209
+ [[ -e "$from" ]] || return 0
210
+ if [[ ! -e "$to" ]]; then
211
+ UNINSTALLED+=("${to}|${from}")
212
+ return 0
213
+ fi
214
+ if [[ ! -r "$to" ]]; then
215
+ UNREADABLE+=("$to")
216
+ return 0
217
+ fi
218
+ # Both sides through the same filter, so only a real difference survives. Note the "#"
219
+ # delimiter: an alternation inside an "s|...|" would end the expression at its first branch, and
220
+ # the "|| true" below would then hide the error and report the two files as identical.
221
+ local norm
222
+ case "$how" in
223
+ plugin) norm='s#^plugin .*#plugin -#' ;;
224
+ # The installer rewrites User, WorkingDirectory and ExecStart for whoever runs it and wherever
225
+ # the server went, so those three are normalised - comparing them exactly would report every
226
+ # installation that is not user "pi" in /home/pi/frugal-iot as out of date at every upgrade.
227
+ service) norm='s#^(User|WorkingDirectory|ExecStart)=.*#\1=-#' ;;
228
+ *) norm='' ;;
229
+ esac
230
+
231
+ case "$how" in
232
+ plugin|service)
233
+ a=$(sed -E "$norm" "$from" | grep -vE '^[[:space:]]*(#|$)' | sort || true)
234
+ b=$(sed -E "$norm" "$to" | grep -vE '^[[:space:]]*(#|$)' | sort || true)
235
+ # Neither of these files is ever empty once the comments are gone. Both blank means the
236
+ # filter failed rather than that they agree, and reporting "current" on that would be the
237
+ # worst answer of the three.
238
+ if [[ -z "$a" || -z "$b" ]]; then
239
+ UNREADABLE+=("$to")
240
+ elif [[ "$a" != "$b" ]]; then
241
+ STALE+=("${to}|${from}")
242
+ fi
243
+ ;;
244
+ subset)
245
+ missing=$(comm -23 <(grep -vE '^[[:space:]]*(#|$)' "$from" | sort -u || true) \
246
+ <(grep -vE '^[[:space:]]*(#|$)' "$to" | sort -u || true) || true)
247
+ [[ -z "$missing" ]] || STALE+=("${to}|${from}")
248
+ ;;
249
+ *)
250
+ cmp -s "$from" "$to" || STALE+=("${to}|${from}")
251
+ ;;
252
+ esac
253
+ }
254
+
255
+ check_installed "${PKG}/extras/mosquitto.conf" /etc/mosquitto/conf.d/frugal-iot.conf plugin
256
+ check_installed "${PKG}/extras/aclfile" /etc/mosquitto/aclfile subset
257
+ check_installed "${PKG}/extras/frugaliot.service" /etc/systemd/system/frugaliot.service service
258
+
185
259
  if (( ${#DIFFER_TO} )); then
186
260
  echo
187
261
  echo "These files were left as you have them, but this release ships a different version:"
@@ -189,9 +263,35 @@ if (( ${#DIFFER_TO} )); then
189
263
  echo " ${DIFFER_TO[$i]}"
190
264
  echo " compare with: diff ${DIFFER_TO[$i]} ${DIFFER_FROM[$i]}"
191
265
  done
192
- echo "Usually that just means you edited it, and there is nothing to do. But a release can also"
193
- echo "change one of these files - and anything installed elsewhere from it, such as"
194
- echo "/etc/mosquitto/conf.d/frugal-iot.conf, keeps the old content until you copy it again."
266
+ echo "The schema describes the sensor types the software understands. If you added one of your"
267
+ echo "own that is exactly as it should be; if you did not, this release changed it and the"
268
+ echo "difference is worth reading."
269
+ fi
270
+
271
+ if (( ${#STALE} )); then
272
+ echo
273
+ echo "Installed elsewhere from extras/, and older than this release:"
274
+ for e in $STALE; do
275
+ echo " ${e%%|*}"
276
+ echo " compare with: sudo diff ${e%%|*} ${e#*|}"
277
+ done
278
+ echo "Re-running the installer brings these up to date and is the safe way to do it - it"
279
+ echo "substitutes the plugin path and will not discard ACL rules added per bridge:"
280
+ echo " bash node_modules/frugal-iot-server/scripts/install-pi.sh"
281
+ fi
282
+
283
+ if (( ${#UNINSTALLED} )); then
284
+ echo
285
+ echo "Nothing at the usual place, so these were not compared:"
286
+ for e in $UNINSTALLED; do echo " ${e%%|*} (would come from extras/${${e#*|}:t})"; done
287
+ echo "Either install-pi.sh has not been run here, or this machine's broker configuration was put"
288
+ echo "together by hand under another name - several small files in /etc/mosquitto/conf.d is a"
289
+ echo "normal way to have done it. Nothing is out of date; there is simply nothing to compare."
290
+ fi
291
+
292
+ if (( ${#UNREADABLE} )); then
293
+ echo
294
+ echo "There, but not readable as $(id -un), so not compared: ${(j:, :)UNREADABLE}"
195
295
  fi
196
296
 
197
297
  echo