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/INSTALLATION.md +180 -142
- package/README.md +3 -0
- package/config.d/schema/modules.yaml +15 -0
- package/frugal-iot-createdb.sql +34 -1
- package/frugal-iot-server.js +117 -5
- package/lib/dynsec-plan.js +75 -1
- package/lib/dynsec-server.js +41 -1
- package/lib/dynsec-sync.js +35 -4
- package/lib/enrol.js +132 -29
- package/package.json +7 -4
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +81 -22
- package/scripts/addorganization.zsh +25 -5
- package/scripts/diagnostic.zsh +149 -27
- package/scripts/init.zsh +106 -6
- package/scripts/install-pi.sh +269 -44
- package/scripts/rebuild-dynsec.js +41 -4
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.
|
|
16
|
-
"frugal-iot-logger": "^2.0.
|
|
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":
|
|
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.
|
|
89
|
+
"version": "2.0.3"
|
|
87
90
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -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.
|
|
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 "$
|
|
161
|
+
if [[ ! -w "$PWDIR" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
|
|
149
162
|
if command -v sudo >/dev/null; then
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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.
|
|
286
|
+
echo "Done. Collect these, then run the command at the bottom on the Pi."
|
|
241
287
|
echo ""
|
|
242
|
-
echo "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
309
|
+
echo "3. THE REPLICA TOKEN, so people registered here can log in on the Pi as well:"
|
|
259
310
|
echo ""
|
|
260
|
-
echo "
|
|
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 "
|
|
266
|
-
echo "
|
|
267
|
-
echo "
|
|
268
|
-
echo "
|
|
269
|
-
|
|
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 "$
|
|
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
|
-
|
|
114
|
-
|
|
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
|
package/scripts/diagnostic.zsh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
67
|
-
#
|
|
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}"
|
|
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 "
|
|
193
|
-
echo "
|
|
194
|
-
echo "
|
|
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
|