frugal-iot-server 2.0.0 → 2.0.2
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 +149 -128
- package/README.md +3 -0
- package/frugal-iot-createdb.sql +34 -1
- package/frugal-iot-server.js +56 -4
- package/lib/dynsec-plan.js +24 -0
- package/lib/dynsec-server.js +34 -1
- package/lib/enrol.js +132 -29
- package/package.json +7 -4
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +48 -19
- package/scripts/addorganization.zsh +25 -5
- package/scripts/check-schema.js +24 -4
- package/scripts/copy-schema-to-examples.zsh +18 -3
- package/scripts/diagnostic.zsh +149 -27
- package/scripts/init.zsh +106 -6
- package/scripts/install-pi.sh +269 -44
- package/scripts/prerelease.zsh +84 -33
package/scripts/install-pi.sh
CHANGED
|
@@ -43,12 +43,15 @@ Usage: bash install-pi.sh [options]
|
|
|
43
43
|
--name "<name>" organization display name
|
|
44
44
|
--email <address> contact address for the organization
|
|
45
45
|
--phone <number> contact phone, "+" and digits only
|
|
46
|
-
--broker-password the
|
|
46
|
+
--broker-password the organization's shared broker credential. Generated, and not needed on a
|
|
47
|
+
Pi installed by this script: nodes are issued their own when they enrol and
|
|
48
|
+
browsers derive their own at login. Set it only to match nodes flashed
|
|
49
|
+
before enrolment existed.
|
|
47
50
|
--superuser-password password for this server's administrator login.
|
|
48
51
|
--login-password web login password for the organization's own account, which is a different
|
|
49
52
|
thing from the broker credential above and should not be the same string.
|
|
50
|
-
|
|
51
|
-
Enter, or if there is no terminal to ask at.
|
|
53
|
+
Either of the two logins, if not given, is asked for - and generated if you
|
|
54
|
+
just press Enter, or if there is no terminal to ask at.
|
|
52
55
|
--broker-host <host> what the browser and the nodes should call this Pi.
|
|
53
56
|
Default: this Pi's hostname with ".local". Use an IP address if you will
|
|
54
57
|
view the dashboard on Android, which cannot resolve ".local" names.
|
|
@@ -130,6 +133,18 @@ as_user_sh() {
|
|
|
130
133
|
# sudo, or nothing if we are already root
|
|
131
134
|
sudo_() { if [[ ${EUID} -eq 0 ]]; then "$@"; else sudo "$@"; fi; }
|
|
132
135
|
|
|
136
|
+
# Read a file under /etc that may be readable only by root. An ordinary user gets "Permission
|
|
137
|
+
# denied" on stderr and nothing on stdout, which is indistinguishable from "the setting you were
|
|
138
|
+
# looking for is not in there" - so a grep over it reports the wrong problem entirely. Try plainly,
|
|
139
|
+
# and fall back to sudo when a file that exists yields nothing.
|
|
140
|
+
read_maybe_root() {
|
|
141
|
+
local f=$1 out
|
|
142
|
+
[[ -e "$f" ]] || return 0
|
|
143
|
+
out="$(cat "$f" 2>/dev/null || true)"
|
|
144
|
+
[[ -n "$out" ]] || out="$(sudo_ cat "$f" 2>/dev/null || true)"
|
|
145
|
+
printf '%s\n' "$out"
|
|
146
|
+
}
|
|
147
|
+
|
|
133
148
|
# ---------------------------------------------------------------- logging
|
|
134
149
|
|
|
135
150
|
if (( DRY_RUN )); then
|
|
@@ -170,6 +185,15 @@ trap 'on_error $LINENO' ERR
|
|
|
170
185
|
|
|
171
186
|
randpw() { ( set +o pipefail; LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 ); }
|
|
172
187
|
|
|
188
|
+
# Everything a native module needs to compile. python3-setuptools is the one that is easy to miss:
|
|
189
|
+
# node-gyp 8 imports Python's distutils, removed from the standard library in 3.12, and setuptools
|
|
190
|
+
# is what puts an importable one back. Without it the build ends in "No module named 'distutils'".
|
|
191
|
+
BUILD_PACKAGES=(build-essential python3-dev python3-setuptools)
|
|
192
|
+
have_build_tools() { dpkg -s "${BUILD_PACKAGES[@]}" >/dev/null 2>&1; }
|
|
193
|
+
install_build_tools() {
|
|
194
|
+
sudo_ env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${BUILD_PACKAGES[@]}"
|
|
195
|
+
}
|
|
196
|
+
|
|
173
197
|
REBOOT_REASONS=()
|
|
174
198
|
|
|
175
199
|
# Size and modification time of every kernel and initrd image, so that the upgrade replacing one can
|
|
@@ -269,18 +293,66 @@ if [[ -r /etc/os-release ]]; then
|
|
|
269
293
|
fi
|
|
270
294
|
[[ -r /proc/device-tree/model ]] && info "board: $(tr -d '\0' < /proc/device-tree/model)"
|
|
271
295
|
ARCH="$(uname -m)"
|
|
296
|
+
# What the USERLAND is, which is not necessarily what "uname -m" says. uname reports the KERNEL, and
|
|
297
|
+
# Raspberry Pi OS 32-bit boots the 64-bit kernel by default on any 64-bit-capable board - so a
|
|
298
|
+
# perfectly ordinary Pi 4 running the 32-bit image reports "armv8l" while every binary on it is
|
|
299
|
+
# 32-bit armhf. npm resolves prebuilt binaries against the userland, so that is what decides whether
|
|
300
|
+
# anything has to be compiled. Deciding it from uname installed no compiler on exactly that
|
|
301
|
+
# configuration, and the install then died part-way through building sqlite3.
|
|
302
|
+
DEB_ARCH="$(dpkg --print-architecture 2>/dev/null || true)"
|
|
272
303
|
MEM_KB="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)"
|
|
273
|
-
info "
|
|
304
|
+
info "kernel: ${ARCH} userland: ${DEB_ARCH:-unknown} memory: $((MEM_KB / 1024)) MB"
|
|
274
305
|
|
|
275
|
-
#
|
|
276
|
-
# which needs a compiler and more memory than the board has.
|
|
306
|
+
# No ready-made sqlite3 binary is published for 32-bit ARM, so there it has to be compiled.
|
|
277
307
|
NEEDS_BUILD_TOOLS=0
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
308
|
+
case "$DEB_ARCH" in
|
|
309
|
+
armhf|armel) NEEDS_BUILD_TOOLS=1 ;;
|
|
310
|
+
arm64|amd64) NEEDS_BUILD_TOOLS=0 ;;
|
|
311
|
+
*)
|
|
312
|
+
# dpkg could not say. Fall back to the kernel, and lean towards installing the compiler: having
|
|
313
|
+
# it and not needing it costs disk, not having it and needing it stops the install dead.
|
|
314
|
+
case "$ARCH" in
|
|
315
|
+
aarch64|arm64|x86_64) NEEDS_BUILD_TOOLS=0 ;;
|
|
316
|
+
*) NEEDS_BUILD_TOOLS=1 ;;
|
|
317
|
+
esac
|
|
318
|
+
;;
|
|
281
319
|
esac
|
|
320
|
+
|
|
321
|
+
# A 64-bit kernel over a 32-bit userland means 64-bit hardware running the 32-bit image - almost
|
|
322
|
+
# always the wrong card written by mistake, and worth saying before the 40 minutes rather than
|
|
323
|
+
# after. It does work, so this warns and carries on rather than refusing.
|
|
324
|
+
WRONG_IMAGE=0
|
|
325
|
+
if [[ "$DEB_ARCH" == armhf ]]; then
|
|
326
|
+
case "$ARCH" in
|
|
327
|
+
aarch64|armv8l) WRONG_IMAGE=1 ;;
|
|
328
|
+
esac
|
|
329
|
+
fi
|
|
330
|
+
if (( WRONG_IMAGE )); then
|
|
331
|
+
BOARD="$( [[ -r /proc/device-tree/model ]] && tr -d '\0' < /proc/device-tree/model || echo "this board" )"
|
|
332
|
+
warn "-----------------------------------------------------------------------"
|
|
333
|
+
warn "${BOARD} is 64-bit, but the OS on the card is 32-bit (armhf)."
|
|
334
|
+
warn "That is almost always the wrong image written to the card."
|
|
335
|
+
warn ""
|
|
336
|
+
warn "It will work, but no ready-made sqlite3 is published for 32-bit ARM, so"
|
|
337
|
+
warn "it has to be compiled: minutes here, about 40 on a Pi Zero W."
|
|
338
|
+
warn ""
|
|
339
|
+
warn "To use the right one instead: write Raspberry Pi OS Lite (64-bit) to the"
|
|
340
|
+
warn "card and start again from step 1. Nothing here is worth keeping yet."
|
|
341
|
+
warn "-----------------------------------------------------------------------"
|
|
342
|
+
if (( ! ASSUME_YES )) && [[ -t 0 ]]; then
|
|
343
|
+
printf ' Carry on with the 32-bit OS anyway? [y/N] ' > /dev/tty
|
|
344
|
+
read -r REPLY < /dev/tty
|
|
345
|
+
[[ "$REPLY" == [yY]* ]] || { echo "Stopped. Nothing has been changed."; exit 0; }
|
|
346
|
+
fi
|
|
347
|
+
fi
|
|
348
|
+
|
|
349
|
+
# One thing at a time, which lowers npm's peak memory as much as it spares the card. This is about
|
|
350
|
+
# how little memory the board has, not about its architecture - a 32-bit Pi 4 has plenty.
|
|
351
|
+
NPM_SLOW_FLAGS=()
|
|
352
|
+
if (( MEM_KB > 0 && MEM_KB < 1200000 )); then NPM_SLOW_FLAGS=(--maxsockets 1 --no-audit --no-fund); fi
|
|
282
353
|
if (( MEM_KB > 0 && MEM_KB < 700000 )); then NEEDS_SWAP=1; else NEEDS_SWAP=0; fi
|
|
283
|
-
(( NEEDS_BUILD_TOOLS )) && info "
|
|
354
|
+
(( NEEDS_BUILD_TOOLS )) && info "no ready-made sqlite3 for ${DEB_ARCH:-this architecture}, so it is compiled here (minutes on a Pi 4, about 40 on a Zero W)"
|
|
355
|
+
(( ${#NPM_SLOW_FLAGS[@]} )) && info "limited memory: npm will be told to do one thing at a time"
|
|
284
356
|
(( NEEDS_SWAP )) && info "under 700 MB of memory: a swap file will be added so the install can finish"
|
|
285
357
|
|
|
286
358
|
ask ORG_ID "Organization id (1-10 lower-case letters or digits, e.g. myfarm)" || exit 1
|
|
@@ -292,7 +364,15 @@ ask ORG_PHONE "Contact phone ('+' and digits only)" || exit 1
|
|
|
292
364
|
# Asked for in the order they matter. Each may be typed, passed as an option, or generated.
|
|
293
365
|
ask_password SUPER_PW "the superuser login (this server's administrator)"
|
|
294
366
|
ask_password LOGIN_PW "the ${ORG_ID} web login"
|
|
295
|
-
|
|
367
|
+
# Not asked for. addorganization.zsh requires one, and lib/retained.js falls back to it on a broker
|
|
368
|
+
# with no dynamic security - but this installer always sets dynamic security up, so on this Pi every
|
|
369
|
+
# node is issued its own credential when it enrols and every browser derives its own when it logs
|
|
370
|
+
# in, and nothing reads this. Generated rather than asked, so there is one less thing to choose.
|
|
371
|
+
# --broker-password still sets it, for an organization whose nodes predate enrolment.
|
|
372
|
+
if [[ -z "$BROKER_PW" ]]; then
|
|
373
|
+
BROKER_PW="$(randpw)"
|
|
374
|
+
GENERATED+=(BROKER_PW)
|
|
375
|
+
fi
|
|
296
376
|
[[ -n "$BROKER_HOST" ]] || BROKER_HOST="$(hostname).local"
|
|
297
377
|
|
|
298
378
|
info "install into: ${INSTALL_DIR} (owned by ${RUN_USER})"
|
|
@@ -315,7 +395,9 @@ $( (( NEEDS_SWAP )) && echo " * add a 2G swap file, because this board has unde
|
|
|
315
395
|
Passwords it would use. A generated one is different on every run, so pass it or type it if you
|
|
316
396
|
want this exact set. All of them are shown here because a dry run writes no log; a real run prints
|
|
317
397
|
only the generated ones, so the ones you chose stay out of the log:
|
|
318
|
-
$(printf ' %-22s %s\n' "superuser login" "${SUPER_PW}" "${ORG_ID} web login" "${LOGIN_PW}"
|
|
398
|
+
$(printf ' %-22s %s\n' "superuser login" "${SUPER_PW}" "${ORG_ID} web login" "${LOGIN_PW}")
|
|
399
|
+
The organization's broker credential is generated too, but not shown: nothing needs you to know it,
|
|
400
|
+
since nodes are issued their own when they enrol. It lands in config.d/organizations/${ORG_ID}.yaml.
|
|
319
401
|
PLAN
|
|
320
402
|
exit 0
|
|
321
403
|
fi
|
|
@@ -363,7 +445,7 @@ fi
|
|
|
363
445
|
|
|
364
446
|
step "Installing the packages the server needs"
|
|
365
447
|
PACKAGES=(nodejs npm sqlite3 zsh curl)
|
|
366
|
-
(( NEEDS_BUILD_TOOLS )) && PACKAGES+=(
|
|
448
|
+
(( NEEDS_BUILD_TOOLS )) && PACKAGES+=("${BUILD_PACKAGES[@]}")
|
|
367
449
|
sudo_ env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${PACKAGES[@]}"
|
|
368
450
|
NODE_VERSION="$(node -v)"
|
|
369
451
|
info "node ${NODE_VERSION}, npm $(npm -v), sqlite3 $(sqlite3 --version | awk '{print $1}')"
|
|
@@ -423,7 +505,22 @@ else
|
|
|
423
505
|
if (( NEEDS_BUILD_TOOLS )); then
|
|
424
506
|
info "this is the slow part - sqlite3 is compiled here. Leave it running."
|
|
425
507
|
fi
|
|
426
|
-
|
|
508
|
+
# A failure here is nearly always the native module having to be compiled with no compiler
|
|
509
|
+
# present - which means the architecture was read wrong above. Rather than stopping and making
|
|
510
|
+
# you run the whole thing again, install what a build needs and have one more go.
|
|
511
|
+
if ! as_user_sh "cd '${INSTALL_DIR}' && npm install ${NPM_SLOW_FLAGS[*]:-} frugal-iot-server"; then
|
|
512
|
+
if have_build_tools; then
|
|
513
|
+
echo "npm install failed, and the compiler it would need is already installed - so this is" >&2
|
|
514
|
+
echo "something else. The reason is above, and in ${LOG}." >&2
|
|
515
|
+
exit 1
|
|
516
|
+
fi
|
|
517
|
+
warn "npm install failed and nothing here can compile a native module - installing the"
|
|
518
|
+
warn " compiler and trying once more. (If it ended in \"No module named 'distutils'\", that"
|
|
519
|
+
warn " is exactly this.)"
|
|
520
|
+
install_build_tools
|
|
521
|
+
NEEDS_BUILD_TOOLS=1
|
|
522
|
+
as_user_sh "cd '${INSTALL_DIR}' && npm install ${NPM_SLOW_FLAGS[*]:-} frugal-iot-server"
|
|
523
|
+
fi
|
|
427
524
|
ok "installed"
|
|
428
525
|
fi
|
|
429
526
|
info "server $(as_user_sh "node -e \"console.log(require('${INSTALL_DIR}/node_modules/frugal-iot-server/package.json').version)\"")"
|
|
@@ -435,6 +532,10 @@ if as_user_sh "cd '${INSTALL_DIR}' && node -e \"require('sqlite3')\"" >/dev/null
|
|
|
435
532
|
ok "sqlite3 loads"
|
|
436
533
|
else
|
|
437
534
|
warn "sqlite3 did not build on the first attempt - rebuilding it"
|
|
535
|
+
if ! have_build_tools; then
|
|
536
|
+
info "installing the compiler first - a rebuild cannot work without it"
|
|
537
|
+
install_build_tools
|
|
538
|
+
fi
|
|
438
539
|
(( NEEDS_BUILD_TOOLS )) && info "expect around 40 minutes on a Pi Zero W; it is working as long as output keeps appearing"
|
|
439
540
|
as_user_sh "cd '${INSTALL_DIR}' && npm rebuild sqlite3 --foreground-scripts"
|
|
440
541
|
as_user_sh "cd '${INSTALL_DIR}' && node -e \"require('sqlite3')\"" >/dev/null 2>&1 \
|
|
@@ -490,15 +591,47 @@ SRC_CONF="${PKG_DIR}/extras/mosquitto.conf"
|
|
|
490
591
|
# refused by a future version. Same rule as the password file below.
|
|
491
592
|
SRC_ACL="${PKG_DIR}/extras/aclfile"
|
|
492
593
|
[[ -f "$SRC_ACL" ]] || SRC_ACL="${INSTALL_DIR}/extras/aclfile"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
594
|
+
[[ -f "$SRC_ACL" ]] || { echo "Expected extras/aclfile in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1; }
|
|
595
|
+
|
|
596
|
+
# An existing ACL file is NOT simply replaced. addbridge-prod.zsh appends a stanza to it per Pi:
|
|
597
|
+
#
|
|
598
|
+
# user <account>
|
|
599
|
+
# topic readwrite <org>/#
|
|
600
|
+
#
|
|
601
|
+
# Overwriting the file drops those, and because a mosquitto acl_file is deny-by-default the result
|
|
602
|
+
# is every bridge account still logging in and reaching nothing at all - every site stops relaying,
|
|
603
|
+
# on a broker that looks perfectly healthy. So the shipped file is treated as the set of lines that
|
|
604
|
+
# must be PRESENT, not as the whole contents.
|
|
605
|
+
#
|
|
606
|
+
# Ownership and mode of a file that already exists are left alone deliberately. An older install has
|
|
607
|
+
# it root-owned 644, which works and only draws a warning from the broker; "fixing" it to 600
|
|
608
|
+
# without also chowning it to mosquitto stops the broker starting, because it reads this file after
|
|
609
|
+
# dropping privileges. That is a migration to make deliberately, not a side effect of an upgrade -
|
|
610
|
+
# INSTALLATION.md step 11 has it.
|
|
611
|
+
if [[ ! -e /etc/mosquitto/aclfile ]]; then
|
|
612
|
+
sudo_ install -o mosquitto -g mosquitto -m 600 "$SRC_ACL" /etc/mosquitto/aclfile
|
|
613
|
+
ok "access control list installed at /etc/mosquitto/aclfile"
|
|
614
|
+
else
|
|
615
|
+
# Read with sudo: the file is normally mode 600 owned by the broker's own user, so reading it as
|
|
616
|
+
# anyone else returns nothing, which would look exactly like "every shipped rule is missing".
|
|
617
|
+
ACL_MISSING=$(comm -23 \
|
|
618
|
+
<(grep -vE '^[[:space:]]*(#|$)' "$SRC_ACL" | sort -u) \
|
|
619
|
+
<(sudo_ grep -vE '^[[:space:]]*(#|$)' /etc/mosquitto/aclfile 2>/dev/null | sort -u) || true)
|
|
620
|
+
ACL_ADDED=$(sudo_ grep -cE '^[[:space:]]*user[[:space:]]' /etc/mosquitto/aclfile 2>/dev/null || true)
|
|
621
|
+
ACL_ADDED=${ACL_ADDED:-0}
|
|
622
|
+
if [[ -z "$ACL_MISSING" ]]; then
|
|
623
|
+
skip "/etc/mosquitto/aclfile has every rule this release ships${ACL_ADDED:+ (plus ${ACL_ADDED} added per bridge)}"
|
|
624
|
+
elif (( ACL_ADDED == 0 )); then
|
|
625
|
+
# Nothing has been added by hand, so there is nothing to lose by replacing it
|
|
626
|
+
sudo_ install -m "$(stat -c '%a' /etc/mosquitto/aclfile 2>/dev/null || echo 600)" \
|
|
627
|
+
"$SRC_ACL" /etc/mosquitto/aclfile
|
|
628
|
+
ok "access control list at /etc/mosquitto/aclfile brought up to date"
|
|
496
629
|
else
|
|
497
|
-
|
|
498
|
-
|
|
630
|
+
warn "/etc/mosquitto/aclfile is missing rules this release ships, and holds ${ACL_ADDED} rule(s)"
|
|
631
|
+
warn " added per bridge, which replacing it would delete. Add these by hand instead:"
|
|
632
|
+
while IFS= read -r line; do [[ -n "$line" ]] && info " ${line}"; done <<< "$ACL_MISSING"
|
|
633
|
+
warn " then: sudo systemctl reload mosquitto"
|
|
499
634
|
fi
|
|
500
|
-
else
|
|
501
|
-
echo "Expected extras/aclfile in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1
|
|
502
635
|
fi
|
|
503
636
|
|
|
504
637
|
# The dynamic security plugin. Its state file has to exist before the configuration naming it is
|
|
@@ -510,12 +643,34 @@ DYNSEC_SO=""
|
|
|
510
643
|
for cand in /usr/lib/*/mosquitto_dynamic_security.so /usr/lib/mosquitto_dynamic_security.so /usr/lib/*/mosquitto/mosquitto_dynamic_security.so /usr/lib/mosquitto/mosquitto_dynamic_security.so; do
|
|
511
644
|
[[ -f "$cand" ]] && { DYNSEC_SO="$cand"; break; }
|
|
512
645
|
done
|
|
646
|
+
# An earlier release appended to this as whoever ran the script, so a run under sudo left it owned
|
|
647
|
+
# by root - unreadable to the server, which is an ordinary user, and the symptom is every login
|
|
648
|
+
# being told there is no broker credential. Put it right before anything below reads it.
|
|
649
|
+
if [[ -f "${INSTALL_DIR}/config.d/secrets.yaml" ]]; then
|
|
650
|
+
SECRETS_OWNER="$(stat -c '%U' "${INSTALL_DIR}/config.d/secrets.yaml" 2>/dev/null || true)"
|
|
651
|
+
if [[ -n "$SECRETS_OWNER" && "$SECRETS_OWNER" != "$RUN_USER" ]]; then
|
|
652
|
+
sudo_ chown "${RUN_USER}" "${INSTALL_DIR}/config.d/secrets.yaml"
|
|
653
|
+
sudo_ chmod 600 "${INSTALL_DIR}/config.d/secrets.yaml"
|
|
654
|
+
ok "config.d/secrets.yaml belonged to ${SECRETS_OWNER}, not ${RUN_USER} - ownership corrected"
|
|
655
|
+
fi
|
|
656
|
+
fi
|
|
657
|
+
|
|
513
658
|
DYNSEC_JSON=/var/lib/mosquitto/dynamic-security.json
|
|
514
659
|
if [[ -z "$DYNSEC_SO" ]]; then
|
|
515
660
|
warn "mosquitto's dynamic security plugin was not found - per-user broker accounts will not work."
|
|
516
661
|
warn " Looked in /usr/lib. The broker will still run on the organization passwords."
|
|
517
662
|
elif [[ -f "$DYNSEC_JSON" ]]; then
|
|
518
|
-
|
|
663
|
+
if as_user_sh "grep -q '^dynsec_admin_password:' '${INSTALL_DIR}/config.d/secrets.yaml'" 2>/dev/null; then
|
|
664
|
+
skip "dynamic security already initialised at ${DYNSEC_JSON}"
|
|
665
|
+
else
|
|
666
|
+
# Re-running cannot mend this: mosquitto_ctrl will not re-initialise over an existing state
|
|
667
|
+
# file, and the password it chose the first time was never written down anywhere.
|
|
668
|
+
warn "${DYNSEC_JSON} exists but config.d/secrets.yaml has no dynsec_admin_password."
|
|
669
|
+
warn " A previous run must have stopped between the two. The password it used is not"
|
|
670
|
+
warn " recoverable, so start that part again:"
|
|
671
|
+
warn " sudo rm ${DYNSEC_JSON}"
|
|
672
|
+
warn " then run this script again. Nothing else is affected - no accounts exist yet."
|
|
673
|
+
fi
|
|
519
674
|
else
|
|
520
675
|
# dynsec init asks for the admin password twice on stdin. The password is generated here and kept
|
|
521
676
|
# in the server's own config.d/secrets.yaml, which is never served to a browser.
|
|
@@ -530,8 +685,8 @@ else
|
|
|
530
685
|
echo "# \$CONTROL/dynamic-security/v1. Created by install-pi.sh."
|
|
531
686
|
echo "dynsec_admin_user: \"frugal-admin\""
|
|
532
687
|
echo "dynsec_admin_password: \"${DYNSEC_PW}\""
|
|
533
|
-
} >>
|
|
534
|
-
|
|
688
|
+
} | as_user_sh "cat >> '${INSTALL_DIR}/config.d/secrets.yaml' && chmod 600 '${INSTALL_DIR}/config.d/secrets.yaml'"
|
|
689
|
+
# Through a pipe, not on the command line: an argument to sudo would put the password in "ps"
|
|
535
690
|
ok "dynamic security initialised; admin credential saved to config.d/secrets.yaml"
|
|
536
691
|
else
|
|
537
692
|
warn "mosquitto_ctrl dynsec init failed - continuing without it"
|
|
@@ -548,19 +703,36 @@ if [[ -n "$DYNSEC_SO" ]]; then
|
|
|
548
703
|
else
|
|
549
704
|
sed -e "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|#plugin (not installed)|" -e "s|^plugin_opt_config_file|#plugin_opt_config_file|" "$SRC_CONF" > "$TMP_CONF"
|
|
550
705
|
fi
|
|
551
|
-
if
|
|
706
|
+
if [[ "$(read_maybe_root /etc/mosquitto/conf.d/frugal-iot.conf)" == "$(cat "$TMP_CONF")" ]]; then
|
|
552
707
|
skip "/etc/mosquitto/conf.d/frugal-iot.conf already current"
|
|
553
708
|
else
|
|
554
|
-
|
|
709
|
+
# "install" and not "cp": cp gives a new destination the mode of its source, and the source here
|
|
710
|
+
# is a mktemp file, which is 600. That left this root-only - unreadable to the grep below, to
|
|
711
|
+
# frugal-iot-init's comparison, and to the diagnostic. It holds paths, not secrets.
|
|
712
|
+
sudo_ install -o root -g root -m 644 "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
|
|
555
713
|
ok "configuration copied to /etc/mosquitto/conf.d/frugal-iot.conf"
|
|
556
714
|
fi
|
|
715
|
+
# Repair the mode even when the contents already matched. A release before this one installed this
|
|
716
|
+
# file with "cp" from a mktemp file, leaving it 600 root - and since the contents are right, the
|
|
717
|
+
# branch above skips, so nothing else would ever put that straight.
|
|
718
|
+
CONF_MODE="$(stat -c '%a' /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null || true)"
|
|
719
|
+
if [[ -n "$CONF_MODE" && "$CONF_MODE" != 644 ]]; then
|
|
720
|
+
sudo_ chown root:root /etc/mosquitto/conf.d/frugal-iot.conf
|
|
721
|
+
sudo_ chmod 644 /etc/mosquitto/conf.d/frugal-iot.conf
|
|
722
|
+
ok "mode corrected to 644 (was ${CONF_MODE}) - it holds paths, not secrets, and several things read it"
|
|
723
|
+
fi
|
|
557
724
|
rm -f "$TMP_CONF"
|
|
558
725
|
# The broker refuses to start if the password file it is told about does not exist. It has to belong
|
|
559
726
|
# to the mosquitto user, because both the broker and mosquitto_passwd warn unless the file belongs to
|
|
560
727
|
# whoever opened it - and mosquitto_passwd writes a temporary file beside it, so the directory has to
|
|
561
728
|
# be writable by that user too.
|
|
562
|
-
PWFILE="$(grep -hE '^[[:space:]]*password_file[[:space:]]'
|
|
563
|
-
[[ -
|
|
729
|
+
PWFILE="$(read_maybe_root /etc/mosquitto/conf.d/frugal-iot.conf | grep -hE '^[[:space:]]*password_file[[:space:]]' | tail -1 | awk '{print $2}' || true)"
|
|
730
|
+
if [[ -z "$PWFILE" ]]; then
|
|
731
|
+
echo "No password_file line found in /etc/mosquitto/conf.d/frugal-iot.conf" >&2
|
|
732
|
+
echo "That file is: $(ls -ld /etc/mosquitto/conf.d/frugal-iot.conf 2>&1)" >&2
|
|
733
|
+
echo "If it cannot be read even with sudo, check what is in it - the broker needs that line." >&2
|
|
734
|
+
exit 1
|
|
735
|
+
fi
|
|
564
736
|
if [[ -f "$PWFILE" ]]; then
|
|
565
737
|
skip "password file ${PWFILE} exists"
|
|
566
738
|
else
|
|
@@ -603,6 +775,19 @@ fi
|
|
|
603
775
|
sudo_ systemctl restart mosquitto # so it re-reads the password file
|
|
604
776
|
sleep 2
|
|
605
777
|
|
|
778
|
+
step "Creating the broker's roles and groups"
|
|
779
|
+
# dynsec init creates only the admin account. The roles and groups every other account is put into
|
|
780
|
+
# are built from lib/dynsec-plan.js by this one command, and nothing else creates them - not the
|
|
781
|
+
# server at startup, not addorganization. Without it the first login fails with
|
|
782
|
+
# "addGroupClient: Group not found" and the dashboard says there is no broker credential.
|
|
783
|
+
# Idempotent by design: it is also how a release that adds a rule delivers it.
|
|
784
|
+
if [[ -n "$DYNSEC_SO" ]]; then
|
|
785
|
+
as_user_sh "cd '${INSTALL_DIR}' && npx --no frugal-iot-rebuild-dynsec"
|
|
786
|
+
ok "roles and groups built from the database"
|
|
787
|
+
else
|
|
788
|
+
skip "no dynamic security plugin, so there are no groups to build"
|
|
789
|
+
fi
|
|
790
|
+
|
|
606
791
|
step "Testing the broker end to end"
|
|
607
792
|
# Proves the account, the password file and the port 1883 listener the nodes use all work together.
|
|
608
793
|
TEST_TOPIC="${ORG_ID}/installtest/hello"
|
|
@@ -627,8 +812,12 @@ rm -f "$SUB_OUT"
|
|
|
627
812
|
step "Running the server as a service"
|
|
628
813
|
# The shipped unit is written for user "pi" installing into /home/pi/frugal-iot, so the three lines
|
|
629
814
|
# that depend on that are rewritten for wherever this actually went.
|
|
630
|
-
|
|
631
|
-
|
|
815
|
+
# From the installed package, not ${INSTALL_DIR}/extras - for the same reason as the broker
|
|
816
|
+
# configuration above. frugal-iot-init never overwrites its copy, so a release that changes the
|
|
817
|
+
# unit would otherwise never reach /etc however often this is re-run.
|
|
818
|
+
SRC_SERVICE="${PKG_DIR}/extras/frugaliot.service"
|
|
819
|
+
[[ -f "$SRC_SERVICE" ]] || SRC_SERVICE="${INSTALL_DIR}/extras/frugaliot.service"
|
|
820
|
+
[[ -f "$SRC_SERVICE" ]] || { echo "Expected extras/frugaliot.service in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1; }
|
|
632
821
|
TMP_SERVICE="$(mktemp)"
|
|
633
822
|
sed -e "s|^User=.*|User=${RUN_USER}|" \
|
|
634
823
|
-e "s|^WorkingDirectory=.*|WorkingDirectory=${INSTALL_DIR}|" \
|
|
@@ -637,9 +826,18 @@ sed -e "s|^User=.*|User=${RUN_USER}|" \
|
|
|
637
826
|
if cmp -s "$TMP_SERVICE" /etc/systemd/system/frugaliot.service 2>/dev/null; then
|
|
638
827
|
skip "service file already current"
|
|
639
828
|
else
|
|
640
|
-
|
|
829
|
+
# "install" not "cp", for the reason given at the broker configuration above: a unit file copied
|
|
830
|
+
# from a mktemp file inherits mode 600, which "systemctl cat" and frugal-iot-init cannot read
|
|
831
|
+
sudo_ install -o root -g root -m 644 "$TMP_SERVICE" /etc/systemd/system/frugaliot.service
|
|
641
832
|
ok "service installed, running as ${RUN_USER} from ${INSTALL_DIR}"
|
|
642
833
|
fi
|
|
834
|
+
# Same repair as for the broker configuration above, and for the same reason
|
|
835
|
+
SERVICE_MODE="$(stat -c '%a' /etc/systemd/system/frugaliot.service 2>/dev/null || true)"
|
|
836
|
+
if [[ -n "$SERVICE_MODE" && "$SERVICE_MODE" != 644 ]]; then
|
|
837
|
+
sudo_ chown root:root /etc/systemd/system/frugaliot.service
|
|
838
|
+
sudo_ chmod 644 /etc/systemd/system/frugaliot.service
|
|
839
|
+
ok "service file mode corrected to 644 (was ${SERVICE_MODE}) - systemctl cat could not read it"
|
|
840
|
+
fi
|
|
643
841
|
rm -f "$TMP_SERVICE"
|
|
644
842
|
sudo_ systemctl daemon-reload
|
|
645
843
|
sudo_ systemctl enable -q frugaliot
|
|
@@ -667,17 +865,21 @@ systemctl is-enabled --quiet frugaliot && ok "will start again at boot"
|
|
|
667
865
|
|
|
668
866
|
IP_ADDR="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
|
669
867
|
|
|
868
|
+
# What a node presents once to be issued its own broker credential. The server generates one per
|
|
869
|
+
# organization at startup, so it exists by now; secrets.yaml is 600 and owned by the server's user.
|
|
870
|
+
ENROL_SECRET="$(as_user_sh "grep -A1 '^enrolment_${ORG_ID}:' '${INSTALL_DIR}/config.d/secrets.yaml' 2>/dev/null | tail -1" 2>/dev/null | sed -e 's/^[[:space:]]*-[[:space:]]*//' -e 's/^"//' -e 's/"$//' || true)"
|
|
871
|
+
|
|
670
872
|
# Only the generated ones are worth printing - and printing a password you typed would put it in the
|
|
671
873
|
# log for no reason
|
|
672
874
|
was_generated() { local v; for v in ${GENERATED[@]+"${GENERATED[@]}"}; do [[ "$v" == "$1" ]] && return 0; done; return 1; }
|
|
673
875
|
shown() { if was_generated "$1"; then echo "${!1}"; else echo "(the one you gave)"; fi; }
|
|
674
876
|
|
|
675
877
|
if (( ${#REBOOT_REASONS[@]} )); then
|
|
676
|
-
REBOOT_TEXT="
|
|
878
|
+
REBOOT_TEXT=" 3. REBOOT NEEDED - $(printf '%s' "${REBOOT_REASONS[0]}")."
|
|
677
879
|
for r in "${REBOOT_REASONS[@]:1}"; do REBOOT_TEXT="${REBOOT_TEXT}"$'\n'" Also: ${r}."; done
|
|
678
880
|
REBOOT_TEXT="${REBOOT_TEXT}"$'\n'" sudo reboot"$'\n'" The server and broker both come back by themselves afterwards."
|
|
679
881
|
else
|
|
680
|
-
REBOOT_TEXT="
|
|
882
|
+
REBOOT_TEXT=" 3. No reboot needed. The kernel running now is the one on disk, and everything
|
|
681
883
|
installed here is already running."
|
|
682
884
|
fi
|
|
683
885
|
|
|
@@ -693,26 +895,49 @@ cat <<SUMMARY
|
|
|
693
895
|
Logins superuser / $(shown SUPER_PW)
|
|
694
896
|
${ORG_ID} / $(shown LOGIN_PW)
|
|
695
897
|
|
|
696
|
-
Broker ws://${BROKER_HOST}:9012 for browsers
|
|
697
|
-
|
|
698
|
-
|
|
898
|
+
Broker ws://${BROKER_HOST}:9012 for browsers - each login is issued its own
|
|
899
|
+
credential, so there is nothing to type in
|
|
900
|
+
${BROKER_HOST}:1883 for sensor nodes, which are issued their own
|
|
901
|
+
credential when they enrol (below)
|
|
699
902
|
|
|
700
903
|
Installed in ${INSTALL_DIR}, running as ${RUN_USER}
|
|
701
904
|
Log of this run ${LOG} (readable only by you; any generated password above is in it)
|
|
702
905
|
|
|
703
906
|
Still to do:
|
|
704
907
|
|
|
705
|
-
1.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
908
|
+
1. A project. Create one on the dashboard before flashing any node: a node says which
|
|
909
|
+
project it is in, and enrolling into one this server does not know is refused.
|
|
910
|
+
Your organization "${ORG_ID}" starts with none.
|
|
911
|
+
|
|
912
|
+
2. Your sensor nodes. This cannot be done from here - it happens on your workstation. In the
|
|
913
|
+
PlatformIO project's platformio-secrets-local.ini:
|
|
914
|
+
|
|
915
|
+
build_flags_local =
|
|
916
|
+
'-D SYSTEM_FRUGAL_ORG="${ORG_ID}"'
|
|
917
|
+
'-D SYSTEM_FRUGAL_PROJECT="<the project from 1>"'
|
|
918
|
+
'-D SYSTEM_MQTT_HOST="${BROKER_HOST}"'
|
|
919
|
+
'-D SYSTEM_MQTT_ENROL_URL="http://${BROKER_HOST}:${PORT}/enrol"'
|
|
920
|
+
'-D SYSTEM_MQTT_ENROL_SECRET="${ENROL_SECRET:-see the dashboard, gear icon -> Nodes}"'
|
|
921
|
+
|
|
922
|
+
http and not https, because this Pi has no certificate yet - see 5 below.
|
|
923
|
+
The node presents that secret once, is issued a broker credential of its own, and stores it.
|
|
924
|
+
The secret grants nothing else, so it is not the thing to guard. Nodes appear on the
|
|
925
|
+
dashboard by themselves once they connect.
|
|
926
|
+
|
|
927
|
+
To rotate it, add a line above the old one in ${INSTALL_DIR}/config.d/secrets.yaml and leave
|
|
928
|
+
the old one until every node flashed with it has enrolled - deleting it first strands them.
|
|
711
929
|
|
|
712
930
|
${REBOOT_TEXT}
|
|
713
931
|
|
|
714
|
-
|
|
715
|
-
|
|
932
|
+
4. Outgoing mail, if you want people to be able to reset a forgotten password themselves.
|
|
933
|
+
Without it only you can, on this Pi, with frugal-iot-setpassword.
|
|
934
|
+
Optional - see INSTALLATION.md step 9a.
|
|
935
|
+
|
|
936
|
+
5. HTTPS. NOT WRITTEN YET: step 10 of INSTALLATION.md describes what it will cover - a DNS
|
|
937
|
+
name, port forwarding, nginx in front of port ${PORT}, and certbot - but there are no
|
|
938
|
+
instructions to follow there yet. Until then this is a plain HTTP server on your own
|
|
939
|
+
network, which is all an offline installation needs. What you do not get is over-the-air
|
|
940
|
+
firmware updates, since an ESP32 requires HTTPS to fetch them.
|
|
716
941
|
|
|
717
942
|
If anything looks wrong:
|
|
718
943
|
cd ${INSTALL_DIR} && npx --no frugal-iot-diagnostic
|