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.
@@ -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 machine credential shared by the server, nodes and dashboards.
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
- Any of these three not given is asked for, and generated if you just press
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 "arch: ${ARCH}, memory: $((MEM_KB / 1024)) MB"
304
+ info "kernel: ${ARCH} userland: ${DEB_ARCH:-unknown} memory: $((MEM_KB / 1024)) MB"
274
305
 
275
- # A 32-bit ARMv6 board (Pi Zero W, Pi 1) has no ready-made sqlite3 binary, so it has to compile it,
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
- NPM_SLOW_FLAGS=()
279
- case "$ARCH" in
280
- armv6l|armv7l) NEEDS_BUILD_TOOLS=1; NPM_SLOW_FLAGS=(--maxsockets 1 --no-audit --no-fund) ;;
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 "32-bit ARM: sqlite3 will be compiled, which is slow (allow 40 minutes on a Zero W)"
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
- ask_password BROKER_PW "the ${ORG_ID} broker credential, shared by the nodes and dashboards"
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}" "${ORG_ID} broker" "${BROKER_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+=(build-essential python3-dev python3-setuptools)
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
- as_user_sh "cd '${INSTALL_DIR}' && npm install ${NPM_SLOW_FLAGS[*]:-} frugal-iot-server"
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
- if [[ -f "$SRC_ACL" ]]; then
494
- if cmp -s "$SRC_ACL" /etc/mosquitto/aclfile 2>/dev/null; then
495
- skip "/etc/mosquitto/aclfile already current"
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
- sudo_ install -o mosquitto -g mosquitto -m 600 "$SRC_ACL" /etc/mosquitto/aclfile
498
- ok "access control list installed at /etc/mosquitto/aclfile"
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
- skip "dynamic security already initialised at ${DYNSEC_JSON}"
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
- } >> "${INSTALL_DIR}/config.d/secrets.yaml"
534
- chmod 600 "${INSTALL_DIR}/config.d/secrets.yaml"
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 cmp -s "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
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
- sudo_ cp "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
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:]]' /etc/mosquitto/conf.d/frugal-iot.conf | tail -1 | awk '{print $2}' || true)"
563
- [[ -n "$PWFILE" ]] || { echo "No password_file line in /etc/mosquitto/conf.d/frugal-iot.conf" >&2; exit 1; }
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
- SRC_SERVICE="${INSTALL_DIR}/extras/frugaliot.service"
631
- [[ -f "$SRC_SERVICE" ]] || { echo "Expected ${SRC_SERVICE} to exist after frugal-iot-init" >&2; exit 1; }
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
- sudo_ cp "$TMP_SERVICE" /etc/systemd/system/frugaliot.service
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=" 2. REBOOT NEEDED - $(printf '%s' "${REBOOT_REASONS[0]}")."
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=" 2. No reboot needed. The kernel running now is the one on disk, and everything
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
- ${BROKER_HOST}:1883 for sensor nodes
698
- user ${ORG_ID}, password $(shown BROKER_PW)
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. Your sensor nodes. This cannot be done from here - a node learns the broker by being
706
- flashed with it, which happens on your workstation. Build each node's firmware with:
707
- broker ${BROKER_HOST}
708
- org ${ORG_ID}
709
- password $(shown BROKER_PW)
710
- They appear on the dashboard by themselves once they connect.
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
- 3. HTTPS, if you want over-the-air firmware updates - ESP32 requires it for those.
715
- See INSTALLATION.md step 10.
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